From ffdfd807d1ad6e6be42854fbaaf429c2871fe90a Mon Sep 17 00:00:00 2001 From: stefan-gorules <127550877+stefan-gorules@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:47:40 +0200 Subject: [PATCH] feat: implement slots instead of natural language (#527) * feat: implement slots instead of natural language Replace the natural-language projection (`nl`) with engine-owned slot completion: for a cursor position the engine classifies the slot being edited (operand, operator, literal, member access, ...) and returns typed options, literal facts and the expected type, for policies and graphs. - zen-expression: new `slot` module (classification, literals, operators) and a lenient lexer mode for incomplete input; `nl` removed - Intellisense: typed diagnostic codes with arguments, richer inspect for hover, dedicated parser error variants (messages unchanged) - Engine: `Workspace::cursor_scope`, `slot`, `facts`; `rename_from` and `references_from`; editor spans in UTF-16 units; renames reach imported policies, `$nodes` reads, child graphs and output column heads; no `$root` completion in policies - Node bindings: `slot`, `facts`, `cursorScope`, `slotBatch` replace `nl`, `nlTokenize`, `nlEncodeString`, `nlTokenizeBatch` No change to expression evaluation: the VM, compiler, functions and the default lexer path are untouched, and parser error messages keep their text. * fix: harden slot classification, scopes and graph expectations - restore master execution order; slot scopes hide writes of the edited block and its dependents instead of relying on block ranking - token-start editing gated by slot state; operator, list and operand spans no longer splice into neighbouring tokens - interval-aware bracket pairing for half-open and reversed intervals - lenient lexer recovers from unknown characters (slots only) - graph expected type follows switch and pass-through nodes and merges compatible output schemas - sibling inference caps distinct values and analyses lazily - deterministic overload return type display * fix: order policy blocks by entity reads through closures and relationships - record reads for `#.field` in closures so entity fields read through lists become dependencies - link reads through derived lists (filter/map results) to the entity fields they carry and order the list after those fields - evaluate entity blocks on single relationships, and demand entity paths for plain reads through relationships - slot scopes keep the top-level variable when hiding written fields --- bindings/nodejs/dts-header.d.ts | 219 +- bindings/nodejs/index.d.ts | 255 +-- bindings/nodejs/index.js | 3 +- bindings/nodejs/src/expression.rs | 57 +- bindings/nodejs/src/lib.rs | 1 + bindings/nodejs/src/policy.rs | 114 +- bindings/nodejs/src/slot.rs | 149 ++ bindings/nodejs/test/workspace.spec.ts | 11 - bindings/nodejs/zen-engine.wasi-browser.js | 3 +- bindings/nodejs/zen-engine.wasi.cjs | 3 +- core/engine/src/policy/blocks/assertion.rs | 30 +- core/engine/src/policy/blocks/context.rs | 46 +- .../src/policy/blocks/decision_table.rs | 181 +- core/engine/src/policy/blocks/expression.rs | 35 +- core/engine/src/policy/blocks/match_block.rs | 56 +- core/engine/src/policy/blocks/mod.rs | 48 +- core/engine/src/policy/evaluator.rs | 18 +- core/engine/src/policy/mod.rs | 13 +- .../engine/src/policy/queries/dependencies.rs | 7 +- core/engine/src/policy/queries/dependency.rs | 186 +- core/engine/src/policy/queries/scope.rs | 63 + core/engine/src/workspace/db.rs | 37 +- core/engine/src/workspace/editor.rs | 325 +-- core/engine/src/workspace/graph/analysis.rs | 121 +- core/engine/src/workspace/graph/cursor.rs | 41 +- .../src/workspace/graph/dependencies.rs | 29 +- core/engine/src/workspace/graph/editor.rs | 259 ++- core/engine/src/workspace/graph/mod.rs | 2 +- core/engine/src/workspace/graph/nl.rs | 60 - core/engine/src/workspace/graph/queries.rs | 20 +- core/engine/src/workspace/mod.rs | 42 +- core/engine/src/workspace/slot/facts.rs | 232 ++ core/engine/src/workspace/slot/mod.rs | 302 +++ core/engine/src/workspace/slot/scope.rs | 390 ++++ core/engine/src/workspace/slot/siblings.rs | 91 + core/engine/src/workspace/types/cursor.rs | 32 +- core/engine/src/workspace/types/diagnostic.rs | 64 +- core/engine/src/workspace/types/mod.rs | 8 +- core/engine/src/workspace/types/nl.rs | 62 - .../engine/tests/data/policy/completions.toml | 31 +- .../tests/data/policy/dependencies.toml | 15 + .../policy/fixtures/completion_scope.json | 63 + .../fixtures/completion_scope_graph.json | 72 + .../tests/data/policy/fixtures/slots.json | 271 +++ .../data/policy/fixtures/slots_graph.json | 216 ++ .../policy/fixtures/slots_graph_dicts.json | 26 + core/engine/tests/data/policy/slots.toml | 382 ++++ core/engine/tests/policy.rs | 154 +- core/engine/tests/policy_dictionary.rs | 235 +- core/engine/tests/policy_nl.rs | 231 -- core/engine/tests/policy_output_types.rs | 84 +- core/engine/tests/policy_toml.rs | 145 +- core/engine/tests/workspace_graph.rs | 507 ++++- .../tests/workspace_graph_dictionary.rs | 39 +- core/expression/README.md | 2 +- core/expression/src/functions/defs.rs | 9 +- .../expression/src/intellisense/completion.rs | 127 +- .../expression/src/intellisense/dependency.rs | 46 +- .../expression/src/intellisense/diagnostic.rs | 231 +- .../expression/src/intellisense/inspection.rs | 110 +- core/expression/src/intellisense/mod.rs | 226 +- .../src/intellisense/type_provider.rs | 77 +- core/expression/src/lexer/lexer.rs | 105 +- core/expression/src/lexer/mod.rs | 4 +- core/expression/src/lib.rs | 2 +- core/expression/src/nl/mod.rs | 73 - core/expression/src/nl/project.rs | 951 -------- core/expression/src/nl/token.rs | 247 -- core/expression/src/parser/ast.rs | 14 +- core/expression/src/parser/mod.rs | 2 +- core/expression/src/parser/parser.rs | 9 +- core/expression/src/parser/standard.rs | 3 +- core/expression/src/slot/classify.rs | 2026 +++++++++++++++++ core/expression/src/slot/literals.rs | 647 ++++++ core/expression/src/slot/mod.rs | 500 ++++ core/expression/src/slot/operators.rs | 61 + core/expression/tests/analysis.rs | 50 +- .../tests/data/analysis/closures.toml | 4 +- .../tests/data/analysis/diagnostics.toml | 52 + .../expression/tests/data/analysis/enums.toml | 10 + .../tests/data/analysis/functions.toml | 19 + .../tests/data/analysis/inspect.toml | 48 + .../tests/data/analysis/nullable.toml | 11 + .../tests/data/analysis/read_spans.toml | 1 + core/expression/tests/data/slots.toml | 588 +++++ core/expression/tests/nl.rs | 1061 --------- core/expression/tests/slots.rs | 328 +++ core/types/src/variable_type/mod.rs | 187 +- core/types/src/variable_type/util.rs | 95 +- 89 files changed, 9549 insertions(+), 4463 deletions(-) create mode 100644 bindings/nodejs/src/slot.rs delete mode 100644 core/engine/src/workspace/graph/nl.rs create mode 100644 core/engine/src/workspace/slot/facts.rs create mode 100644 core/engine/src/workspace/slot/mod.rs create mode 100644 core/engine/src/workspace/slot/scope.rs create mode 100644 core/engine/src/workspace/slot/siblings.rs delete mode 100644 core/engine/src/workspace/types/nl.rs create mode 100644 core/engine/tests/data/policy/fixtures/completion_scope.json create mode 100644 core/engine/tests/data/policy/fixtures/completion_scope_graph.json create mode 100644 core/engine/tests/data/policy/fixtures/slots.json create mode 100644 core/engine/tests/data/policy/fixtures/slots_graph.json create mode 100644 core/engine/tests/data/policy/fixtures/slots_graph_dicts.json create mode 100644 core/engine/tests/data/policy/slots.toml delete mode 100644 core/engine/tests/policy_nl.rs delete mode 100644 core/expression/src/nl/mod.rs delete mode 100644 core/expression/src/nl/project.rs delete mode 100644 core/expression/src/nl/token.rs create mode 100644 core/expression/src/slot/classify.rs create mode 100644 core/expression/src/slot/literals.rs create mode 100644 core/expression/src/slot/mod.rs create mode 100644 core/expression/src/slot/operators.rs create mode 100644 core/expression/tests/data/slots.toml delete mode 100644 core/expression/tests/nl.rs create mode 100644 core/expression/tests/slots.rs diff --git a/bindings/nodejs/dts-header.d.ts b/bindings/nodejs/dts-header.d.ts index ecf85c37..142c360c 100644 --- a/bindings/nodejs/dts-header.d.ts +++ b/bindings/nodejs/dts-header.d.ts @@ -21,7 +21,7 @@ export type PolicyRenameTarget = export type PolicyCursorTarget = | { kind: 'expression'; id: string } | { kind: 'assertionOutput' } - | { kind: 'expressionKey' } + | { kind: 'expressionKey'; id?: string } | { kind: 'matchTarget' } | { kind: 'matchValue'; id: string } | { kind: 'decisionTableHead'; col: string } @@ -92,153 +92,100 @@ export type PolicyVariableType = | { type: 'object'; fields: Record } | { type: 'nullable'; inner: PolicyVariableType }; -/** Language-agnostic symbol key for an infix operator. The client maps the key to a localized phrase. */ -export type NlOpSym = - | 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne' | 'in' | 'notIn' - | 'contains' | 'notContains' - | 'containsAny' | 'containsAll' | 'containsNone' | 'containsOnly' - | 'add' | 'sub' | 'mul' | 'div' | 'mod' | 'pow' - | 'and' | 'or' | 'not' | 'coalesce'; - /** - * Structural separator word inside a multi-operand construct (conditional, closure, interval). - * `has` replaces `where` when an alias-elided closure body leads with a member on the binding - * ("any drivers has age less than 5"); clients should shorten the op label that follows it. + * Slot-aware autocomplete. `PolicyExpressionCursor.pos` passed to `slot()`, every `span` and + * `replaceSpan` below are UTF-16 code units (JavaScript string offsets), not bytes. */ -export type NlWordSym = 'if' | 'then' | 'otherwise' | 'in' | 'where' | 'has' | 'rangeAnd'; - -/** - * Resolved type of a value/field token. `enum` / `array` carry an `index` - * into the per-result `enums` table; dedupe the domain once, reference by index. - */ -export type NlTypeTag = - | { t: 'number' } - | { t: 'string' } - | { t: 'bool' } - | { t: 'date' } - | { t: 'interval' } - | { t: 'object' } - | { t: 'null' } - | { t: 'unknown' } - | { t: 'enum'; index: number } - | { t: 'array'; items: NlTypeTag }; - -/** One operator the user may switch to: the symbol for labelling plus its ZEN source form for splicing. */ -export interface NlOpChoice { - sym: NlOpSym; - source: string; -} - -/** - * One enum domain value: `label` for display, `source` as the ready-to-splice ZEN literal. - * `source` is absent when the value contains both quote kinds and has no literal form. - */ -export interface NlEnumOption { +export type PolicySlotState = + | 'start' + | 'unaryStart' + | 'value' + | 'listElement' + | 'range' + | 'operator' + | 'logical' + | 'argument' + | 'member' + | 'closure' + | 'inString' + | 'path'; +export type PolicySlotRole = 'unary' | 'condition' | 'value' | 'path'; +/** One enum value: `label` for display, `source` the ready-to-splice ZEN literal (null when unquotable). */ +export interface PolicyValueOption { + value: string; label: string; - source?: string; + source: string | null; } - -/** - * Widget hint present only when it adds information beyond the token's own type: enum domains - * (`options` indexes the `enums` table), expected dates, or the operator choices valid for the - * operand types — ordered comparisons only for number/date/unknown operands, eq/ne otherwise. - */ -/** - * `quantSelect` marks a quantified-membership phrase (contains any/all/none/only). `subject` and - * `list` are ready-to-splice ZEN source slices; the client rebuilds the whole spanned expression - * from its canonical template for the picked symbol. `funcSelect` swaps a closure-function name - * in place (all/some/none); the token span covers exactly the name. - */ -export type NlEditHint = - | { kind: 'datePicker' } - | { kind: 'select'; options: number } - | { kind: 'multiSelect'; options: number } - | { kind: 'opSelect'; options: NlOpChoice[] } - | { kind: 'quantSelect'; options: NlOpSym[]; subject: string; list: string } - | { kind: 'funcSelect'; options: string[] }; - -/** - * One token in the symbolic NL stream. Structure is explicit via the - * group/list/interval markers and the infix placement of `op` / `word` tokens. - * `func.sym` / `method.sym` are stable camelCase keys (`'sum'`, `'format'`, `'d'` - * for the date constructor); `closure: true` marks a closure call whose operands - * follow as `element`, `word:'in'`, collection, `word:'where'`, body. - */ -export type NlTokenKind = - | { t: 'groupOpen' } - | { t: 'groupClose' } - | { t: 'listOpen' } - | { t: 'listClose' } - | { t: 'comma' } - | { t: 'enumList'; selected: string[] } - | { t: 'context' } - | { t: 'root' } - | { t: 'null' } - | { t: 'field'; path: string[]; ty: NlTypeTag } - | { t: 'element'; alias?: string } - | { t: 'number'; value: string } - | { t: 'str'; value: string } - | { t: 'bool'; value: boolean } - | { t: 'op'; sym: NlOpSym; implied: boolean; between: boolean } - | { t: 'word'; sym: NlWordSym } - | { t: 'assign' } - | { t: 'stmtEnd' } - | { t: 'func'; sym: string; closure: boolean } - | { t: 'method'; sym: string } - | { t: 'templateOpen' } - | { t: 'templateText'; value: string } - | { t: 'templateClose' } - | { t: 'intervalOpen'; inclusive: boolean } - | { t: 'intervalClose'; inclusive: boolean } - | { t: 'code'; source: string }; - -/** A projected token plus its source span (`[start, end)` bytes) and optional widget hint. */ -export interface NlToken { - token: NlTokenKind; - span: PolicySpan; - hint?: NlEditHint; +export interface PolicyEnumTable { + name: string | null; + options: PolicyValueOption[]; } - -export type NlDiagnosticSource = 'lexer' | 'parser' | 'typeCheck' | 'compiler'; - -export interface NlDiagnostic { - span: PolicySpan; - message: string; - severity: PolicySeverity; - source: NlDiagnosticSource; +export interface PolicySlot { + state: PolicySlotState; + expected: PolicyVariableType | null; + operand: PolicyVariableType | null; + options: PolicyValueOption[]; + operators: string[]; + function: string | null; + argument: number | null; + /** `[start, end)` UTF-16 range the accepted suggestion replaces. */ + replaceSpan: PolicySpan; + inString: string | null; + listed: string[]; + autoOpen: boolean; + /** Names bound by enclosing closures at the caret, innermost first (`x` for `as x`, `#` otherwise). */ + locals: Array<{ name: string; type: PolicyVariableType }>; } - -/** Result of projecting one expression; `enums` is the dedup table referenced by `NlTypeTag` / `NlEditHint` indices. */ -export interface NlResult { - id: string; - tokens: NlToken[]; - enums: NlEnumOption[][]; - diagnostics: NlDiagnostic[]; - /** Resolved `$` type for unary requests (decision-table input cells); present even for empty text. */ - subjectType?: PolicyVariableType; - /** Labeled options when the unary subject is an enum (dictionary labels applied); present even for empty text. */ - subjectOptions?: NlEnumOption[]; +export type PolicyLiteralFact = + | { + kind: 'enum'; + span: PolicySpan; + value: string; + name: string | null; + label: string; + valid: boolean; + enumIndex: number; + } + | { + kind: 'date'; + span: PolicySpan; + arg: + | { kind: 'now' } + | { kind: 'today' } + | { kind: 'literal'; value: string; valid: boolean; tz?: string } + | { kind: 'field'; path: string }; + } + | { kind: 'bool'; span: PolicySpan; value: boolean }; +/** Result of `slot(cursor, text)` and of each `slotBatch` request. */ +export interface PolicySlotResponse { + /** Whether the source is a complete, syntactically valid expression. */ + complete?: boolean; + kind: 'standard' | 'unary'; + role: PolicySlotRole; + /** `$` type for unary cells, otherwise the expected value type. */ + subjectType: PolicyVariableType | null; + expectedType: PolicyVariableType | null; + slot: PolicySlot; + literals: PolicyLiteralFact[]; + enums: PolicyEnumTable[]; } - /** - * One projected expression/cell of a policy, returned by `PolicyWorkspace.nl(policyPath)`. - * The engine resolves scope + (for unary decision-table input cells) the subject type - * internally, so no `rootType` is supplied. `target` routes the result to the editor it - * came from (assertion condition, match arm, decision-table cell `{row, col}`, …). + * One expression location of a policy or graph, returned by `facts(policyPath)`. `source` is the + * stored text the facts were computed from; `subjectOptions` lists labeled enum values for an + * empty unary or value cell. */ -export interface PolicyNlExpression { +export interface PolicyExpressionFacts { + complete?: boolean; blockId: string; target: PolicyCursorTarget; - kind: 'standard' | 'unary'; - /** Expression text this projection was computed from — compare against the editor value to detect staleness. */ source: string; - tokens: NlToken[]; - enums: NlEnumOption[][]; - diagnostics: NlDiagnostic[]; - /** Resolved `$` type for unary cells. */ - subjectType?: PolicyVariableType; - /** Labeled options when the unary subject is an enum (dictionary labels applied). */ - subjectOptions?: NlEnumOption[]; + kind: 'standard' | 'unary'; + role: PolicySlotRole; + subjectType: PolicyVariableType | null; + expectedType: PolicyVariableType | null; + literals: PolicyLiteralFact[]; + enums: PolicyEnumTable[]; + subjectOptions: PolicyValueOption[]; } /** diff --git a/bindings/nodejs/index.d.ts b/bindings/nodejs/index.d.ts index cbe7fd83..1938d360 100644 --- a/bindings/nodejs/index.d.ts +++ b/bindings/nodejs/index.d.ts @@ -21,7 +21,7 @@ export type PolicyRenameTarget = export type PolicyCursorTarget = | { kind: 'expression'; id: string } | { kind: 'assertionOutput' } - | { kind: 'expressionKey' } + | { kind: 'expressionKey'; id?: string } | { kind: 'matchTarget' } | { kind: 'matchValue'; id: string } | { kind: 'decisionTableHead'; col: string } @@ -92,153 +92,100 @@ export type PolicyVariableType = | { type: 'object'; fields: Record } | { type: 'nullable'; inner: PolicyVariableType }; -/** Language-agnostic symbol key for an infix operator. The client maps the key to a localized phrase. */ -export type NlOpSym = - | 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne' | 'in' | 'notIn' - | 'contains' | 'notContains' - | 'containsAny' | 'containsAll' | 'containsNone' | 'containsOnly' - | 'add' | 'sub' | 'mul' | 'div' | 'mod' | 'pow' - | 'and' | 'or' | 'not' | 'coalesce'; - /** - * Structural separator word inside a multi-operand construct (conditional, closure, interval). - * `has` replaces `where` when an alias-elided closure body leads with a member on the binding - * ("any drivers has age less than 5"); clients should shorten the op label that follows it. + * Slot-aware autocomplete. `PolicyExpressionCursor.pos` passed to `slot()`, every `span` and + * `replaceSpan` below are UTF-16 code units (JavaScript string offsets), not bytes. */ -export type NlWordSym = 'if' | 'then' | 'otherwise' | 'in' | 'where' | 'has' | 'rangeAnd'; - -/** - * Resolved type of a value/field token. `enum` / `array` carry an `index` - * into the per-result `enums` table; dedupe the domain once, reference by index. - */ -export type NlTypeTag = - | { t: 'number' } - | { t: 'string' } - | { t: 'bool' } - | { t: 'date' } - | { t: 'interval' } - | { t: 'object' } - | { t: 'null' } - | { t: 'unknown' } - | { t: 'enum'; index: number } - | { t: 'array'; items: NlTypeTag }; - -/** One operator the user may switch to: the symbol for labelling plus its ZEN source form for splicing. */ -export interface NlOpChoice { - sym: NlOpSym; - source: string; -} - -/** - * One enum domain value: `label` for display, `source` as the ready-to-splice ZEN literal. - * `source` is absent when the value contains both quote kinds and has no literal form. - */ -export interface NlEnumOption { +export type PolicySlotState = + | 'start' + | 'unaryStart' + | 'value' + | 'listElement' + | 'range' + | 'operator' + | 'logical' + | 'argument' + | 'member' + | 'closure' + | 'inString' + | 'path'; +export type PolicySlotRole = 'unary' | 'condition' | 'value' | 'path'; +/** One enum value: `label` for display, `source` the ready-to-splice ZEN literal (null when unquotable). */ +export interface PolicyValueOption { + value: string; label: string; - source?: string; + source: string | null; } - -/** - * Widget hint present only when it adds information beyond the token's own type: enum domains - * (`options` indexes the `enums` table), expected dates, or the operator choices valid for the - * operand types — ordered comparisons only for number/date/unknown operands, eq/ne otherwise. - */ -/** - * `quantSelect` marks a quantified-membership phrase (contains any/all/none/only). `subject` and - * `list` are ready-to-splice ZEN source slices; the client rebuilds the whole spanned expression - * from its canonical template for the picked symbol. `funcSelect` swaps a closure-function name - * in place (all/some/none); the token span covers exactly the name. - */ -export type NlEditHint = - | { kind: 'datePicker' } - | { kind: 'select'; options: number } - | { kind: 'multiSelect'; options: number } - | { kind: 'opSelect'; options: NlOpChoice[] } - | { kind: 'quantSelect'; options: NlOpSym[]; subject: string; list: string } - | { kind: 'funcSelect'; options: string[] }; - -/** - * One token in the symbolic NL stream. Structure is explicit via the - * group/list/interval markers and the infix placement of `op` / `word` tokens. - * `func.sym` / `method.sym` are stable camelCase keys (`'sum'`, `'format'`, `'d'` - * for the date constructor); `closure: true` marks a closure call whose operands - * follow as `element`, `word:'in'`, collection, `word:'where'`, body. - */ -export type NlTokenKind = - | { t: 'groupOpen' } - | { t: 'groupClose' } - | { t: 'listOpen' } - | { t: 'listClose' } - | { t: 'comma' } - | { t: 'enumList'; selected: string[] } - | { t: 'context' } - | { t: 'root' } - | { t: 'null' } - | { t: 'field'; path: string[]; ty: NlTypeTag } - | { t: 'element'; alias?: string } - | { t: 'number'; value: string } - | { t: 'str'; value: string } - | { t: 'bool'; value: boolean } - | { t: 'op'; sym: NlOpSym; implied: boolean; between: boolean } - | { t: 'word'; sym: NlWordSym } - | { t: 'assign' } - | { t: 'stmtEnd' } - | { t: 'func'; sym: string; closure: boolean } - | { t: 'method'; sym: string } - | { t: 'templateOpen' } - | { t: 'templateText'; value: string } - | { t: 'templateClose' } - | { t: 'intervalOpen'; inclusive: boolean } - | { t: 'intervalClose'; inclusive: boolean } - | { t: 'code'; source: string }; - -/** A projected token plus its source span (`[start, end)` bytes) and optional widget hint. */ -export interface NlToken { - token: NlTokenKind; - span: PolicySpan; - hint?: NlEditHint; +export interface PolicyEnumTable { + name: string | null; + options: PolicyValueOption[]; } - -export type NlDiagnosticSource = 'lexer' | 'parser' | 'typeCheck' | 'compiler'; - -export interface NlDiagnostic { - span: PolicySpan; - message: string; - severity: PolicySeverity; - source: NlDiagnosticSource; +export interface PolicySlot { + state: PolicySlotState; + expected: PolicyVariableType | null; + operand: PolicyVariableType | null; + options: PolicyValueOption[]; + operators: string[]; + function: string | null; + argument: number | null; + /** `[start, end)` UTF-16 range the accepted suggestion replaces. */ + replaceSpan: PolicySpan; + inString: string | null; + listed: string[]; + autoOpen: boolean; + /** Names bound by enclosing closures at the caret, innermost first (`x` for `as x`, `#` otherwise). */ + locals: Array<{ name: string; type: PolicyVariableType }>; } - -/** Result of projecting one expression; `enums` is the dedup table referenced by `NlTypeTag` / `NlEditHint` indices. */ -export interface NlResult { - id: string; - tokens: NlToken[]; - enums: NlEnumOption[][]; - diagnostics: NlDiagnostic[]; - /** Resolved `$` type for unary requests (decision-table input cells); present even for empty text. */ - subjectType?: PolicyVariableType; - /** Labeled options when the unary subject is an enum (dictionary labels applied); present even for empty text. */ - subjectOptions?: NlEnumOption[]; +export type PolicyLiteralFact = + | { + kind: 'enum'; + span: PolicySpan; + value: string; + name: string | null; + label: string; + valid: boolean; + enumIndex: number; + } + | { + kind: 'date'; + span: PolicySpan; + arg: + | { kind: 'now' } + | { kind: 'today' } + | { kind: 'literal'; value: string; valid: boolean; tz?: string } + | { kind: 'field'; path: string }; + } + | { kind: 'bool'; span: PolicySpan; value: boolean }; +/** Result of `slot(cursor, text)` and of each `slotBatch` request. */ +export interface PolicySlotResponse { + /** Whether the source is a complete, syntactically valid expression. */ + complete?: boolean; + kind: 'standard' | 'unary'; + role: PolicySlotRole; + /** `$` type for unary cells, otherwise the expected value type. */ + subjectType: PolicyVariableType | null; + expectedType: PolicyVariableType | null; + slot: PolicySlot; + literals: PolicyLiteralFact[]; + enums: PolicyEnumTable[]; } - /** - * One projected expression/cell of a policy, returned by `PolicyWorkspace.nl(policyPath)`. - * The engine resolves scope + (for unary decision-table input cells) the subject type - * internally, so no `rootType` is supplied. `target` routes the result to the editor it - * came from (assertion condition, match arm, decision-table cell `{row, col}`, …). + * One expression location of a policy or graph, returned by `facts(policyPath)`. `source` is the + * stored text the facts were computed from; `subjectOptions` lists labeled enum values for an + * empty unary or value cell. */ -export interface PolicyNlExpression { +export interface PolicyExpressionFacts { + complete?: boolean; blockId: string; target: PolicyCursorTarget; - kind: 'standard' | 'unary'; - /** Expression text this projection was computed from — compare against the editor value to detect staleness. */ source: string; - tokens: NlToken[]; - enums: NlEnumOption[][]; - diagnostics: NlDiagnostic[]; - /** Resolved `$` type for unary cells. */ - subjectType?: PolicyVariableType; - /** Labeled options when the unary subject is an enum (dictionary labels applied). */ - subjectOptions?: NlEnumOption[]; + kind: 'standard' | 'unary'; + role: PolicySlotRole; + subjectType: PolicyVariableType | null; + expectedType: PolicyVariableType | null; + literals: PolicyLiteralFact[]; + enums: PolicyEnumTable[]; + subjectOptions: PolicyValueOption[]; } /** @@ -457,12 +404,10 @@ export declare class Workspace { outputs(req: PolicyScopeRequest): Array conditionalSchema(req: PolicyScopeRequest): PolicyConditionalSchema inspect(cursor: PolicyExpressionCursor): PolicyInspectResult | null - nl(policyPath: string): PolicyNlExpression[] - nlTokenize(cursor: PolicyExpressionCursor, text: string): NlResult | null completions(cursor: PolicyExpressionCursor): Array prepareRename(cursor: PolicyExpressionCursor): PolicyPrepareRenameResult | null rename(req: PolicyRenameRequest): PolicyEngineEdit[] - references(target: any): PolicyReferenceSite[] + references(target: any, origin?: string | undefined | null): PolicyReferenceSite[] search(query: string, limit?: number | undefined | null): PolicySearchHit[] inputSkeleton(req: PolicyScopeRequest): unknown dependencies(target: string, document?: string | undefined | null): PolicyDependencyNode @@ -471,6 +416,8 @@ export declare class Workspace { enhanceGraphTrace(path: string, trace: any): PolicyTrace componentMembers(policy: string): Array crossComponentWriteConflicts(): Array + slot(cursor: PolicyExpressionCursor, text: string): PolicySlotResponse | null + facts(policyPath: string): PolicyExpressionFacts[] } export declare class ZenDecision { @@ -526,17 +473,6 @@ export declare function evaluateUnaryExpression(expression: string, context: any export declare function evaluateUnaryExpressionSync(expression: string, context: any): boolean -export declare function nlEncodeString(value: string): string | null - -export declare function nlTokenizeBatch(requests: NlTokenizeRequest[], rootType: PolicyVariableType, strict?: boolean): NlResult[] - -export interface NlTokenizeRequest { - id: string - expression: string - unary: boolean - subjectType?: PolicyVariableType -} - export declare function overrideConfig(config: ZenConfig): void export interface PolicyCompletion { @@ -544,6 +480,7 @@ export interface PolicyCompletion { kind: string detail: string info: string + follow?: string } export interface PolicyConditionalSchema { @@ -562,6 +499,8 @@ export interface PolicyDiagnostic { span?: PolicySpan expressionId?: string target?: PolicyCursorTarget + exprCode?: string + args?: Record } export interface PolicyDictionaryEntryInfo { @@ -644,6 +583,8 @@ export interface PolicyInspectResult { span: PolicySpan kind: PolicyVariableType label: string + detail?: string + info?: string } export interface PolicyInstanceOf { @@ -677,6 +618,7 @@ export interface PolicyRemoveBlockRequest { export interface PolicyRenameRequest { target: PolicyRenameTarget newName: string + origin?: string } export interface PolicySchemaGroup { @@ -703,6 +645,19 @@ export declare function renderTemplate(template: string, context: any): Promise< export declare function renderTemplateSync(template: string, context: any): any +export declare function slotBatch(requests: Array, strict?: boolean | undefined | null): Array<{ id: string; result: PolicySlotResponse }> + +export interface SlotRequest { + id: string + text: string + pos: number + unary: boolean + role: PolicySlotRole + scope: PolicyVariableType + expected?: PolicyVariableType | null + labels?: Record> | null +} + export interface ZenConfig { nodesInContext?: boolean functionTimeoutMillis?: number diff --git a/bindings/nodejs/index.js b/bindings/nodejs/index.js index 937748aa..f9c63638 100644 --- a/bindings/nodejs/index.js +++ b/bindings/nodejs/index.js @@ -581,8 +581,7 @@ module.exports.evaluateExpression = nativeBinding.evaluateExpression module.exports.evaluateExpressionSync = nativeBinding.evaluateExpressionSync module.exports.evaluateUnaryExpression = nativeBinding.evaluateUnaryExpression module.exports.evaluateUnaryExpressionSync = nativeBinding.evaluateUnaryExpressionSync -module.exports.nlEncodeString = nativeBinding.nlEncodeString -module.exports.nlTokenizeBatch = nativeBinding.nlTokenizeBatch module.exports.overrideConfig = nativeBinding.overrideConfig module.exports.renderTemplate = nativeBinding.renderTemplate module.exports.renderTemplateSync = nativeBinding.renderTemplateSync +module.exports.slotBatch = nativeBinding.slotBatch diff --git a/bindings/nodejs/src/expression.rs b/bindings/nodejs/src/expression.rs index 0767a9b2..48e09918 100644 --- a/bindings/nodejs/src/expression.rs +++ b/bindings/nodejs/src/expression.rs @@ -60,62 +60,7 @@ pub async fn render_template(template: String, context: Value) -> napi::Result Option { - zen_expression::nl::encode_string(&value) -} - -#[napi(object)] -pub struct NlTokenizeRequest { - pub id: String, - pub expression: String, - pub unary: bool, - #[napi(ts_type = "PolicyVariableType")] - pub subject_type: Option, -} - -#[napi( - ts_args_type = "requests: NlTokenizeRequest[], rootType: PolicyVariableType, strict?: boolean", - ts_return_type = "NlResult[]" -)] -pub fn nl_tokenize_batch( - requests: Vec, - root_type: Value, - strict: Option, -) -> napi::Result> { - use zen_expression::intellisense::IntelliSense; - use zen_expression::nl::NlRequest; - - let root = json_to_variable_type(&root_type); - let core_requests: Vec = requests - .into_iter() - .map(|request| NlRequest { - id: request.id, - expression: request.expression, - unary: request.unary, - subject_type: request.subject_type.as_ref().map(json_to_variable_type), - }) - .collect(); - - let mut intellisense = IntelliSense::new().with_strict(strict.unwrap_or(false)); - intellisense - .nl_tokenize_batch(&core_requests, &root) - .iter() - .map(|result| { - let mut value = serde_json::to_value(result) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - if let (Some(subject), Some(obj)) = (&result.subject_type, value.as_object_mut()) { - obj.insert( - "subjectType".into(), - crate::policy::variable_type_to_json(subject), - ); - } - Ok(value) - }) - .collect() -} - -fn json_to_variable_type(value: &Value) -> zen_expression::variable::VariableType { +pub(crate) fn json_to_variable_type(value: &Value) -> zen_expression::variable::VariableType { use std::rc::Rc; use zen_expression::variable::VariableType as VT; diff --git a/bindings/nodejs/src/lib.rs b/bindings/nodejs/src/lib.rs index 2d96805c..7b58f066 100644 --- a/bindings/nodejs/src/lib.rs +++ b/bindings/nodejs/src/lib.rs @@ -11,4 +11,5 @@ mod loader; mod mt; mod policy; mod safe_result; +mod slot; mod types; diff --git a/bindings/nodejs/src/policy.rs b/bindings/nodejs/src/policy.rs index f5cb6707..95f43254 100644 --- a/bindings/nodejs/src/policy.rs +++ b/bindings/nodejs/src/policy.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use napi::anyhow::anyhow; @@ -57,6 +58,8 @@ pub struct PolicyDiagnostic { pub expression_id: Option, #[napi(ts_type = "PolicyCursorTarget")] pub target: Option, + pub expr_code: Option, + pub args: Option>, } impl From<&workspace::Diagnostic> for PolicyDiagnostic { @@ -80,6 +83,13 @@ impl From<&workspace::Diagnostic> for PolicyDiagnostic { .target .as_ref() .and_then(|t| serde_json::to_value(t).ok()), + expr_code: d.expr_code.map(String::from), + args: (!d.args.is_empty()).then(|| { + d.args + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() + }), } } } @@ -280,6 +290,8 @@ pub struct PolicyInspectResult { #[napi(ts_type = "PolicyVariableType")] pub kind: Value, pub label: String, + pub detail: Option, + pub info: Option, } #[napi(object)] @@ -296,6 +308,7 @@ pub struct PolicyCompletion { pub kind: String, pub detail: String, pub info: String, + pub follow: Option, } #[napi(object)] @@ -318,6 +331,7 @@ pub struct PolicyRenameRequest { #[napi(ts_type = "PolicyRenameTarget")] pub target: Value, pub new_name: String, + pub origin: Option, } #[napi(object)] @@ -406,7 +420,14 @@ fn variable_type_from_json(value: &Value) -> zen_expression::variable::VariableT } pub(crate) fn variable_type_to_json(vt: &zen_expression::variable::VariableType) -> Value { - use zen_expression::variable::VariableType; + variable_type_to_json_at(vt, &mut Vec::new()) +} + +fn variable_type_to_json_at( + vt: &zen_expression::variable::VariableType, + path: &mut Vec<*const ()>, +) -> Value { + use zen_expression::variable::{VariableType, MAX_TYPE_DEPTH}; match vt { VariableType::Any => serde_json::json!({ "type": "any" }), @@ -427,14 +448,18 @@ pub(crate) fn variable_type_to_json(vt: &zen_expression::variable::VariableType) } VariableType::Array(inner) => serde_json::json!({ "type": "array", - "items": variable_type_to_json(inner), + "items": variable_type_to_json_at(inner, path), }), VariableType::Object(obj) => { - let fields: serde_json::Map = obj - .borrow() - .iter() - .map(|(k, v)| (k.to_string(), variable_type_to_json(v))) - .collect(); + let ptr = std::rc::Rc::as_ptr(obj) as *const (); + let mut fields = serde_json::Map::new(); + if path.len() < MAX_TYPE_DEPTH && !path.contains(&ptr) { + path.push(ptr); + for (k, v) in obj.borrow().iter() { + fields.insert(k.to_string(), variable_type_to_json_at(v, path)); + } + path.pop(); + } serde_json::json!({ "type": "object", "fields": fields, @@ -442,7 +467,7 @@ pub(crate) fn variable_type_to_json(vt: &zen_expression::variable::VariableType) } VariableType::Nullable(inner) => serde_json::json!({ "type": "nullable", - "inner": variable_type_to_json(inner), + "inner": variable_type_to_json_at(inner, path), }), } } @@ -491,7 +516,7 @@ pub struct PolicyFunctionResolutionRequest { #[napi] pub struct Workspace { - inner: workspace::Workspace, + pub(crate) inner: workspace::Workspace, resolver: Option, } @@ -510,7 +535,7 @@ impl Workspace { } } - fn ensure_function_types(&self, env: &Env) -> napi::Result<()> { + pub(crate) fn ensure_function_types(&self, env: &Env) -> napi::Result<()> { let Some(resolver) = &self.resolver else { return Ok(()); }; @@ -788,64 +813,11 @@ impl Workspace { span: vec![result.span.0, result.span.1], kind: variable_type_to_json(&result.kind), label: result.label, + detail: result.detail, + info: result.info, })) } - #[napi(ts_return_type = "PolicyNlExpression[]")] - pub fn nl(&self, env: Env, policy_path: String) -> napi::Result> { - self.ensure_function_types(&env)?; - self.inner - .nl(&policy_path) - .iter() - .map(|e| { - let mut value = serde_json::to_value(&e.result) - .map_err(|err| napi::Error::from_reason(err.to_string()))?; - let obj = value - .as_object_mut() - .ok_or_else(|| napi::Error::from_reason("nl result is not an object"))?; - obj.remove("id"); - obj.insert("blockId".into(), Value::String(e.block_id.to_string())); - obj.insert( - "kind".into(), - serde_json::to_value(e.kind) - .map_err(|err| napi::Error::from_reason(err.to_string()))?, - ); - obj.insert( - "target".into(), - serde_json::to_value(&e.target) - .map_err(|err| napi::Error::from_reason(err.to_string()))?, - ); - obj.insert("source".into(), Value::String(e.source.clone())); - if let Some(subject) = &e.result.subject_type { - obj.insert("subjectType".into(), variable_type_to_json(subject)); - } - Ok(value) - }) - .collect() - } - - #[napi(ts_return_type = "NlResult | null")] - pub fn nl_tokenize( - &self, - env: Env, - cursor: PolicyExpressionCursor, - text: String, - ) -> napi::Result> { - self.ensure_function_types(&env)?; - let cursor: workspace::Cursor = cursor.try_into()?; - self.inner - .nl_tokenize(&cursor, &text) - .map(|result| { - let mut value = serde_json::to_value(&result) - .map_err(|err| napi::Error::from_reason(err.to_string()))?; - if let (Some(subject), Some(obj)) = (&result.subject_type, value.as_object_mut()) { - obj.insert("subjectType".into(), variable_type_to_json(subject)); - } - Ok(value) - }) - .transpose() - } - #[napi] pub fn completions( &self, @@ -866,6 +838,7 @@ impl Workspace { .unwrap_or_default(), detail: c.detail, info: c.info, + follow: c.follow.map(String::from), }) .collect()) } @@ -894,20 +867,25 @@ impl Workspace { .map_err(|e| napi::Error::from_reason(e.to_string()))?; Ok(self .inner - .rename(&target, &req.new_name) + .rename_from(&target, &req.new_name, req.origin.as_deref()) .into_iter() .map(|e| serde_json::to_value(e).expect("EngineEdit serializes")) .collect()) } #[napi(ts_return_type = "PolicyReferenceSite[]")] - pub fn references(&self, env: Env, target: Value) -> napi::Result> { + pub fn references( + &self, + env: Env, + target: Value, + origin: Option, + ) -> napi::Result> { self.ensure_function_types(&env)?; let target: workspace::RenameTarget = serde_json::from_value(target).map_err(|e| napi::Error::from_reason(e.to_string()))?; Ok(self .inner - .references(&target) + .references_from(&target, origin.as_deref()) .into_iter() .map(|s| serde_json::to_value(s).expect("ReferenceSite serializes")) .collect()) diff --git a/bindings/nodejs/src/slot.rs b/bindings/nodejs/src/slot.rs new file mode 100644 index 00000000..5cac120c --- /dev/null +++ b/bindings/nodejs/src/slot.rs @@ -0,0 +1,149 @@ +use std::collections::HashMap; +use std::rc::Rc; + +use napi::Env; +use napi_derive::napi; +use serde_json::{json, Value}; +use zen_engine::workspace::{self, CursorScope, ExpressionKind, SlotResponse, SlotRole}; +use zen_expression::intellisense::IntelliSense; +use zen_expression::slot::LabelResolver; +use zen_expression::variable::VariableType; + +use crate::expression::json_to_variable_type; +use crate::policy::{variable_type_to_json, PolicyExpressionCursor, Workspace}; + +#[napi] +impl Workspace { + #[napi(ts_return_type = "PolicySlotResponse | null")] + pub fn slot( + &self, + env: Env, + cursor: PolicyExpressionCursor, + text: String, + ) -> napi::Result> { + self.ensure_function_types(&env)?; + let cursor: workspace::Cursor = cursor.try_into()?; + self.inner + .slot(&cursor, &text) + .map(slot_response_json) + .transpose() + } + + #[napi(ts_return_type = "PolicyExpressionFacts[]")] + pub fn facts(&self, env: Env, policy_path: String) -> napi::Result> { + self.ensure_function_types(&env)?; + self.inner + .facts(&policy_path) + .iter() + .map(|facts| { + let mut value = to_json(facts)?; + let obj = object_mut(&mut value)?; + obj.insert("subjectType".into(), type_json(&facts.subject_type)); + obj.insert("expectedType".into(), type_json(&facts.expected_type)); + Ok(value) + }) + .collect() + } +} + +#[napi(object)] +pub struct SlotRequest { + pub id: String, + pub text: String, + pub pos: u32, + pub unary: bool, + #[napi(ts_type = "PolicySlotRole")] + pub role: String, + #[napi(ts_type = "PolicyVariableType")] + pub scope: Value, + #[napi(ts_type = "PolicyVariableType | null")] + pub expected: Option, + #[napi(ts_type = "Record> | null")] + pub labels: Option, +} + +#[napi(ts_return_type = "Array<{ id: string; result: PolicySlotResponse }>")] +pub fn slot_batch(requests: Vec, strict: Option) -> napi::Result> { + let mut is = IntelliSense::new().with_strict(strict.unwrap_or(false)); + let mut out = Vec::with_capacity(requests.len()); + for request in requests { + let role = parse_role(&request.role)?; + let scope = CursorScope { + kind: if request.unary { + ExpressionKind::Unary + } else { + ExpressionKind::Standard + }, + role, + scope: json_to_variable_type(&request.scope), + expected: request.expected.as_ref().map(json_to_variable_type), + inferred: false, + }; + is.set_labels(label_resolver(request.labels.as_ref())); + let response = SlotResponse::compute(&mut is, &scope, &request.text, request.pos); + out.push(serde_json::json!({ + "id": request.id, + "result": slot_response_json(response)?, + })); + } + is.set_labels(None); + Ok(out) +} + +fn parse_role(role: &str) -> napi::Result { + match role { + "unary" => Ok(SlotRole::Unary), + "condition" => Ok(SlotRole::Condition), + "value" => Ok(SlotRole::Value), + "path" => Ok(SlotRole::Path), + other => Err(napi::Error::from_reason(format!( + "invalid slot role: {other}" + ))), + } +} + +fn label_resolver(labels: Option<&Value>) -> Option { + let map: HashMap> = + serde_json::from_value(labels?.clone()).ok()?; + if map.is_empty() { + return None; + } + Some(Rc::new(move |name: &str, value: &str| { + map.get(name)?.get(value).cloned() + })) +} + +fn slot_response_json(response: SlotResponse) -> napi::Result { + let mut value = to_json(&response)?; + let obj = object_mut(&mut value)?; + obj.insert("subjectType".into(), type_json(&response.subject_type)); + obj.insert("expectedType".into(), type_json(&response.expected_type)); + let slot = obj + .get_mut("slot") + .ok_or_else(|| napi::Error::from_reason("slot response has no slot"))?; + let slot = object_mut(slot)?; + slot.insert("expected".into(), type_json(&response.slot.expected)); + slot.insert("operand".into(), type_json(&response.slot.operand)); + let locals = response + .slot + .locals + .iter() + .map(|l| json!({ "name": l.name, "type": variable_type_to_json(&l.kind) })) + .collect(); + slot.insert("locals".into(), Value::Array(locals)); + Ok(value) +} + +fn to_json(value: &T) -> napi::Result { + serde_json::to_value(value).map_err(|e| napi::Error::from_reason(e.to_string())) +} + +fn object_mut(value: &mut Value) -> napi::Result<&mut serde_json::Map> { + value + .as_object_mut() + .ok_or_else(|| napi::Error::from_reason("expected a JSON object")) +} + +fn type_json(t: &Option) -> Value { + t.as_ref().map(variable_type_to_json).unwrap_or(Value::Null) +} diff --git a/bindings/nodejs/test/workspace.spec.ts b/bindings/nodejs/test/workspace.spec.ts index 2a4a10f9..45fc3406 100644 --- a/bindings/nodejs/test/workspace.spec.ts +++ b/bindings/nodejs/test/workspace.spec.ts @@ -289,15 +289,4 @@ describe('Workspace graph decision tables', () => { const outputs = ws.outputs({ policyPath: 'g' }); expect(outputs.find((o: any) => o.path === 'score')?.resolvedType).toEqual({ type: 'number' }); }); - - it('projects nl for typed output cells with the declared subject type', () => { - const ws = new Workspace(); - ws.setDocument('g', typedTableGraph('number', ['10'])); - - const result = ws.nlTokenize( - { policyPath: 'g', blockId: 'dt', pos: 0, target: { kind: 'decisionTableCell', row: 'r0', col: 'o1' } }, - '10', - ); - expect(result?.subjectType).toEqual({ type: 'number' }); - }); }); diff --git a/bindings/nodejs/zen-engine.wasi-browser.js b/bindings/nodejs/zen-engine.wasi-browser.js index a23ebd00..c0988ef1 100644 --- a/bindings/nodejs/zen-engine.wasi-browser.js +++ b/bindings/nodejs/zen-engine.wasi-browser.js @@ -65,8 +65,7 @@ export const evaluateExpression = __napiModule.exports.evaluateExpression export const evaluateExpressionSync = __napiModule.exports.evaluateExpressionSync export const evaluateUnaryExpression = __napiModule.exports.evaluateUnaryExpression export const evaluateUnaryExpressionSync = __napiModule.exports.evaluateUnaryExpressionSync -export const nlEncodeString = __napiModule.exports.nlEncodeString -export const nlTokenizeBatch = __napiModule.exports.nlTokenizeBatch export const overrideConfig = __napiModule.exports.overrideConfig export const renderTemplate = __napiModule.exports.renderTemplate export const renderTemplateSync = __napiModule.exports.renderTemplateSync +export const slotBatch = __napiModule.exports.slotBatch diff --git a/bindings/nodejs/zen-engine.wasi.cjs b/bindings/nodejs/zen-engine.wasi.cjs index 33201a8c..e2142f86 100644 --- a/bindings/nodejs/zen-engine.wasi.cjs +++ b/bindings/nodejs/zen-engine.wasi.cjs @@ -117,8 +117,7 @@ module.exports.evaluateExpression = __napiModule.exports.evaluateExpression module.exports.evaluateExpressionSync = __napiModule.exports.evaluateExpressionSync module.exports.evaluateUnaryExpression = __napiModule.exports.evaluateUnaryExpression module.exports.evaluateUnaryExpressionSync = __napiModule.exports.evaluateUnaryExpressionSync -module.exports.nlEncodeString = __napiModule.exports.nlEncodeString -module.exports.nlTokenizeBatch = __napiModule.exports.nlTokenizeBatch module.exports.overrideConfig = __napiModule.exports.overrideConfig module.exports.renderTemplate = __napiModule.exports.renderTemplate module.exports.renderTemplateSync = __napiModule.exports.renderTemplateSync +module.exports.slotBatch = __napiModule.exports.slotBatch diff --git a/core/engine/src/policy/blocks/assertion.rs b/core/engine/src/policy/blocks/assertion.rs index 0282abee..a5f68360 100644 --- a/core/engine/src/policy/blocks/assertion.rs +++ b/core/engine/src/policy/blocks/assertion.rs @@ -2,13 +2,11 @@ use std::sync::Arc; use ahash::HashSet; use serde::{Deserialize, Serialize}; -use zen_expression::intellisense::IntelliSense; use zen_expression::variable::{Variable, VariableType}; use zen_expression::Isolate; use crate::workspace::types::{ BlockTrace, ConditionTrace, Cursor, CursorTarget, Diagnostic, DiagnosticCode, ExpressionKind, - NlExpression, }; use crate::policy::ArcStrTrim; @@ -220,32 +218,6 @@ impl AssertionIr { }) } - pub(super) fn nl( - &self, - policy_path: &Arc, - block_id: &Arc, - scope: &VariableType, - is: &mut IntelliSense, - ) -> Vec { - self.conditions - .iter() - .filter(|condition| !condition.expression.is_empty()) - .map(|condition| { - NlExpression::project( - is, - policy_path, - block_id, - CursorTarget::Expression { - id: condition.id.clone(), - }, - ExpressionKind::Standard, - condition.expression.as_ref(), - scope, - ) - }) - .collect() - } - pub(super) fn resolve_cursor( &self, cursor: &Cursor, @@ -257,7 +229,7 @@ impl AssertionIr { } CursorTarget::Expression { id } => { let cond = self.conditions.iter().find(|c| c.id == *id)?; - Some((cond.expression.clone(), ExpressionKind::Unary, scope)) + Some((cond.expression.clone(), ExpressionKind::Standard, scope)) } _ => None, } diff --git a/core/engine/src/policy/blocks/context.rs b/core/engine/src/policy/blocks/context.rs index 6f22ee15..4e518327 100644 --- a/core/engine/src/policy/blocks/context.rs +++ b/core/engine/src/policy/blocks/context.rs @@ -9,12 +9,12 @@ use zen_expression::{Isolate, IsolateError}; use super::property_read::ReadFlattener; use super::type_check::TypeCheck; use crate::policy::ir::PropertyPath; -use crate::policy::queries::dependency::PathPrefix; +use crate::policy::queries::dependency::{DataModelPaths, PathPrefix}; use crate::policy::queries::scope::VariableTypeScope; use crate::workspace::db::AnalysisPass; use crate::workspace::types::{ - CursorTarget, Diagnostic, DiagnosticCode, DiagnosticLocation, ExpressionKind, Severity, - WriteTrace, + CursorTarget, Diagnostic, DiagnosticArgs, DiagnosticCode, DiagnosticLocation, ExpressionKind, + Severity, WriteTrace, }; pub type SharedIntelliSense = Rc>; @@ -59,6 +59,7 @@ pub struct ExecutionError { pub type SharedDictionaryTypes = Rc, VariableType>>; pub type SharedPoisonedPaths = Rc>>>; +pub type SharedDeclaredPaths = Rc; pub struct AnalysisContext { scope: VariableType, @@ -71,9 +72,12 @@ pub struct AnalysisContext { intellisense: SharedIntelliSense, dictionary_types: SharedDictionaryTypes, poisoned_paths: SharedPoisonedPaths, + declared_paths: SharedDeclaredPaths, + default_target: Option, } impl AnalysisContext { + #[allow(clippy::too_many_arguments)] pub fn new( scope: VariableType, policy_path: Arc, @@ -82,6 +86,7 @@ impl AnalysisContext { pass: AnalysisPass, dictionary_types: SharedDictionaryTypes, poisoned_paths: SharedPoisonedPaths, + declared_paths: SharedDeclaredPaths, ) -> Self { Self { scope, @@ -94,6 +99,8 @@ impl AnalysisContext { intellisense, dictionary_types, poisoned_paths, + declared_paths, + default_target: None, } } @@ -105,6 +112,17 @@ impl AnalysisContext { &self.dictionary_types } + pub fn with_target( + &mut self, + target: Option, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + let previous = std::mem::replace(&mut self.default_target, target); + let result = f(self); + self.default_target = previous; + result + } + pub fn analyze_standard( &mut self, source: &Arc, @@ -197,7 +215,9 @@ impl AnalysisContext { } self.poisoned_paths.borrow_mut().insert(path.clone()); } - if matches!(self.pass, AnalysisPass::Enriched) { + if matches!(self.pass, AnalysisPass::Enriched) + && self.declared_paths.matches_prefix(&path).is_none() + { self.scope.insert_at_path(&path, &resolved_type, true); } self.writes.push(WriteTarget { @@ -268,6 +288,20 @@ impl AnalysisContext { .push(Diagnostic::error(code, location, message)); } + pub fn error_with_expr_code( + &mut self, + code: DiagnosticCode, + expr_code: &'static str, + args: DiagnosticArgs, + expression_id: Option>, + span: Option<(u32, u32)>, + message: impl Into, + ) { + let location = self.location_with(expression_id, span); + self.diagnostics + .push(Diagnostic::error(code, location, message).with_expr_code(expr_code, args)); + } + pub fn hint_with_target( &mut self, code: DiagnosticCode, @@ -294,7 +328,7 @@ impl AnalysisContext { block_id: Some(self.block_id.clone()), expression_id, span, - target: None, + target: self.default_target.clone(), } } @@ -429,7 +463,7 @@ impl AnalysisContext { block_id: Some(self.block_id.clone()), expression_id: expression_id.clone(), span: Some(diag.span), - target: None, + target: self.default_target.clone(), }; self.diagnostics .push(Diagnostic::from_expression(diag, location)); diff --git a/core/engine/src/policy/blocks/decision_table.rs b/core/engine/src/policy/blocks/decision_table.rs index 8b3506c4..8b3c61d7 100644 --- a/core/engine/src/policy/blocks/decision_table.rs +++ b/core/engine/src/policy/blocks/decision_table.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, OnceLock}; use ahash::{HashMap, HashSet}; use fixedbitset::FixedBitSet; use serde::{Deserialize, Serialize}; -use zen_expression::intellisense::{ArmTest, IntelliSense, NumberCover}; +use zen_expression::intellisense::{ArmTest, NumberCover}; use zen_expression::variable::{Variable, VariableType}; use zen_expression::Isolate; use zen_types::decision::{ @@ -14,8 +14,8 @@ use base64::Engine as _; use crate::policy::queries::scope::VariableTypeScope; use crate::workspace::types::{ - BlockTrace, Cursor, CursorTarget, DecisionTableExtras, Diagnostic, DiagnosticCode, - ExpressionKind, NlExpression, + BlockTrace, Cursor, CursorTarget, DecisionTableExtras, Diagnostic, DiagnosticArgs, + DiagnosticCode, ExpressionKind, }; use crate::policy::ArcStrTrim; @@ -33,7 +33,7 @@ pub(crate) struct TableSelection { input_bits: Option>, } -const ROW_ID_KEY: &str = "_id"; +pub(crate) const ROW_ID_KEY: &str = "_id"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -355,7 +355,11 @@ impl DecisionTableIr { let Some(field) = col.field.as_ref().filter(|f| !f.is_empty()) else { continue; }; - let field_analysis = cx.analyze_standard(field, Some(col.id.clone())); + let head = Some(CursorTarget::DecisionTableHead { + col: col.id.clone(), + }); + let field_analysis = + cx.with_target(head, |cx| cx.analyze_standard(field, Some(col.id.clone()))); input_field_types.insert(col.id.clone(), field_analysis.return_type.clone()); input_cell_scopes.insert( col.id.clone(), @@ -364,28 +368,37 @@ impl DecisionTableIr { } for rule in &self.rules { + let row_id = rule.get(ROW_ID_KEY).cloned(); for col in &self.inputs { let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { continue; }; + let target = row_id.clone().map(|row| CursorTarget::DecisionTableCell { + row, + col: col.id.clone(), + }); - if let Some(cell_scope) = input_cell_scopes.get(&col.id) { - cx.analyze_unary_in_scope(cell, cell_scope, Some(col.id.clone())); - continue; - } + cx.with_target(target, |cx| { + if let Some(cell_scope) = input_cell_scopes.get(&col.id) { + cx.analyze_unary_in_scope(cell, cell_scope, Some(col.id.clone())); + return; + } - let analysis = cx.analyze_standard(cell, Some(col.id.clone())); - if !matches!(analysis.return_type, VariableType::Bool | VariableType::Any) { - cx.error( - DiagnosticCode::TypeMismatch, - Some(col.id.clone()), - None, - format!( - "input condition must return a boolean, got {:?}", - analysis.return_type - ), - ); - } + let analysis = cx.analyze_standard(cell, Some(col.id.clone())); + if !matches!(analysis.return_type, VariableType::Bool | VariableType::Any) { + cx.error_with_expr_code( + DiagnosticCode::TypeMismatch, + "type.condition-not-bool", + DiagnosticArgs::from([("got", analysis.return_type.to_string())]), + Some(col.id.clone()), + None, + format!( + "input condition must return a boolean, got {:?}", + analysis.return_type + ), + ); + } + }); } } @@ -442,7 +455,15 @@ impl DecisionTableIr { let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else { continue; }; - let analysis = cx.analyze_standard(cell, Some(col.id.clone())); + let cell_target = rule + .get(ROW_ID_KEY) + .map(|row| CursorTarget::DecisionTableCell { + row: row.clone(), + col: col.id.clone(), + }); + let analysis = cx.with_target(cell_target, |cx| { + cx.analyze_standard(cell, Some(col.id.clone())) + }); match &declared { Some(expected) => { let actual = &analysis.return_type; @@ -860,122 +881,6 @@ impl DecisionTableIr { self.commit(cx, &selection) } - pub(super) fn nl( - &self, - policy_path: &Arc, - block_id: &Arc, - scope: &VariableType, - is: &mut IntelliSense, - dictionaries: &HashMap, VariableType>, - ) -> Vec { - let mut out = Vec::new(); - let mut input_scopes: HashMap, (ExpressionKind, VariableType)> = - HashMap::default(); - - for col in &self.inputs { - match col.field.as_ref().filter(|f| !f.is_empty()) { - Some(field) => { - out.push(NlExpression::project( - is, - policy_path, - block_id, - CursorTarget::DecisionTableHead { - col: col.id.clone(), - }, - ExpressionKind::Standard, - field.as_ref(), - scope, - )); - let field_type = is.analyze(field.as_ref(), scope).return_type.clone(); - input_scopes.insert( - col.id.clone(), - (ExpressionKind::Unary, scope.with_dollar(&field_type)), - ); - } - None => { - input_scopes.insert( - col.id.clone(), - (ExpressionKind::Standard, scope.shallow_clone()), - ); - } - } - } - - for rule in &self.rules { - let Some(row) = rule.get(ROW_ID_KEY) else { - continue; - }; - for col in &self.inputs { - let cell: &str = rule.get(&col.id).map(|c| c.as_ref()).unwrap_or(""); - let Some((kind, cell_scope)) = input_scopes.get(&col.id) else { - continue; - }; - out.push(NlExpression::project( - is, - policy_path, - block_id, - CursorTarget::DecisionTableCell { - row: row.clone(), - col: col.id.clone(), - }, - *kind, - cell, - cell_scope, - )); - } - for col in &self.outputs { - let cell: &str = rule.get(&col.id).map(|c| c.as_ref()).unwrap_or(""); - let expected = col.declared.as_ref().and_then(|d| d.resolve(dictionaries)); - out.push(NlExpression::project_expected( - is, - policy_path, - block_id, - CursorTarget::DecisionTableCell { - row: row.clone(), - col: col.id.clone(), - }, - ExpressionKind::Standard, - cell, - scope, - expected.as_ref(), - )); - } - } - - out - } - - pub(super) fn nl_scope( - &self, - cursor: &Cursor, - scope: VariableType, - is: &mut IntelliSense, - dictionaries: &HashMap, VariableType>, - ) -> (ExpressionKind, VariableType, Option) { - let CursorTarget::DecisionTableCell { col, .. } = &cursor.target else { - return (ExpressionKind::Standard, scope, None); - }; - match self.column_by_id(col) { - Some(ColumnRef::Input(column)) => { - match column.field.as_ref().filter(|f| !f.is_empty()) { - Some(field) => { - let field_type = is.analyze(field.as_ref(), &scope).return_type.clone(); - (ExpressionKind::Unary, scope.with_dollar(&field_type), None) - } - None => (ExpressionKind::Standard, scope, None), - } - } - Some(ColumnRef::Output(column)) => { - let expected = column - .declared - .as_ref() - .and_then(|d| d.resolve(dictionaries)); - (ExpressionKind::Standard, scope, expected) - } - None => (ExpressionKind::Standard, scope, None), - } - } - pub(super) fn resolve_cursor( &self, cursor: &Cursor, diff --git a/core/engine/src/policy/blocks/expression.rs b/core/engine/src/policy/blocks/expression.rs index acf46697..18b68e20 100644 --- a/core/engine/src/policy/blocks/expression.rs +++ b/core/engine/src/policy/blocks/expression.rs @@ -2,11 +2,10 @@ use std::sync::Arc; use ahash::HashSet; use serde::{Deserialize, Serialize}; -use zen_expression::intellisense::IntelliSense; use zen_expression::variable::{Variable, VariableType}; use crate::workspace::types::{ - BlockTrace, Cursor, CursorTarget, Diagnostic, DiagnosticCode, ExpressionKind, NlExpression, + BlockTrace, Cursor, CursorTarget, Diagnostic, DiagnosticCode, ExpressionKind, }; use crate::policy::ArcStrTrim; @@ -56,7 +55,7 @@ impl ExpressionIr { } else if let Err(reason) = WriteTarget::validate_path(&key) { cx.target_error( id, - CursorTarget::ExpressionKey, + CursorTarget::ExpressionKey { id: None }, Some((0, key.chars().count() as u32)), DiagnosticCode::InvalidWritePath, format!("invalid write path '{key}': {reason}"), @@ -118,7 +117,8 @@ impl ExpressionIr { } pub(super) fn write_target(&self, path: &str) -> Option { - (!self.key.is_empty() && self.key.as_ref() == path).then_some(CursorTarget::ExpressionKey) + (!self.key.is_empty() && self.key.as_ref() == path) + .then_some(CursorTarget::ExpressionKey { id: None }) } pub(super) fn analyze(&self, cx: &mut AnalysisContext) { @@ -134,7 +134,7 @@ impl ExpressionIr { self.key.clone(), analysis.return_type.clone(), None, - Some(CursorTarget::ExpressionKey), + Some(CursorTarget::ExpressionKey { id: None }), instance_source, ); } @@ -160,36 +160,13 @@ impl ExpressionIr { }) } - pub(super) fn nl( - &self, - policy_path: &Arc, - block_id: &Arc, - scope: &VariableType, - is: &mut IntelliSense, - ) -> Vec { - if self.value.is_empty() { - return Vec::new(); - } - vec![NlExpression::project( - is, - policy_path, - block_id, - CursorTarget::Expression { - id: self.id.clone(), - }, - ExpressionKind::Standard, - self.value.as_ref(), - scope, - )] - } - pub(super) fn resolve_cursor( &self, cursor: &Cursor, scope: VariableType, ) -> Option<(Arc, ExpressionKind, VariableType)> { match &cursor.target { - CursorTarget::ExpressionKey => { + CursorTarget::ExpressionKey { .. } => { Some((self.key.clone(), ExpressionKind::Standard, scope)) } CursorTarget::Expression { .. } => { diff --git a/core/engine/src/policy/blocks/match_block.rs b/core/engine/src/policy/blocks/match_block.rs index 2ad5bab5..d96a5f7b 100644 --- a/core/engine/src/policy/blocks/match_block.rs +++ b/core/engine/src/policy/blocks/match_block.rs @@ -2,14 +2,14 @@ use std::sync::Arc; use ahash::HashSet; use serde::{Deserialize, Serialize}; -use zen_expression::intellisense::{ArmTest, IntelliSense, NumberCover}; +use zen_expression::intellisense::{ArmTest, NumberCover}; use zen_expression::variable::{Variable, VariableType}; use crate::policy::queries::scope::VariableTypeScope; use crate::workspace::types::{ - BlockTrace, ConditionTrace, Cursor, CursorTarget, Diagnostic, DiagnosticCode, ExpressionKind, - NlExpression, + BlockTrace, ConditionTrace, Cursor, CursorTarget, Diagnostic, DiagnosticArgs, DiagnosticCode, + ExpressionKind, }; use crate::policy::ArcStrTrim; @@ -199,8 +199,10 @@ impl MatchIr { } else { let analysis = cx.analyze_standard(&arm.condition, Some(arm.id.clone())); if !matches!(analysis.return_type, VariableType::Bool | VariableType::Any) { - cx.error( + cx.error_with_expr_code( DiagnosticCode::TypeMismatch, + "type.condition-not-bool", + DiagnosticArgs::from([("got", analysis.return_type.to_string())]), Some(arm.id.clone()), None, format!( @@ -414,52 +416,6 @@ impl MatchIr { self.commit(cx, &selection) } - pub(super) fn nl( - &self, - policy_path: &Arc, - block_id: &Arc, - scope: &VariableType, - is: &mut IntelliSense, - ) -> Vec { - let mut out = Vec::new(); - if !self.key.is_empty() { - out.push(NlExpression::project( - is, - policy_path, - block_id, - CursorTarget::MatchTarget, - ExpressionKind::Standard, - self.key.as_ref(), - scope, - )); - } - for arm in &self.arms { - if !arm.condition.is_empty() { - out.push(NlExpression::project( - is, - policy_path, - block_id, - CursorTarget::Expression { id: arm.id.clone() }, - ExpressionKind::Standard, - arm.condition.as_ref(), - scope, - )); - } - if !arm.value.is_empty() { - out.push(NlExpression::project( - is, - policy_path, - block_id, - CursorTarget::MatchValue { id: arm.id.clone() }, - ExpressionKind::Standard, - arm.value.as_ref(), - scope, - )); - } - } - out - } - pub(super) fn resolve_cursor( &self, cursor: &Cursor, diff --git a/core/engine/src/policy/blocks/mod.rs b/core/engine/src/policy/blocks/mod.rs index 594c8467..a14c8d39 100644 --- a/core/engine/src/policy/blocks/mod.rs +++ b/core/engine/src/policy/blocks/mod.rs @@ -9,12 +9,11 @@ mod type_check; use std::sync::Arc; use ahash::{HashMap, HashMapExt, HashSet}; -use zen_expression::intellisense::IntelliSense; use zen_expression::variable::VariableType; use crate::workspace::types::{ BlockTrace, Cursor, CursorTarget, Diagnostic, DiagnosticCode, DiagnosticLocation, - ExpressionKind, NlExpression, Span, + ExpressionKind, Span, }; #[derive(Debug, Clone)] @@ -29,11 +28,11 @@ pub use assertion::{AssertionDoc, AssertionIr}; pub(crate) use context::IntelliSenseSource; pub use context::{ AnalysisContext, AnalysisSummary, ExecutionContext, ExecutionError, ExpressionLocation, - InstanceSource, PropertyRead, SharedDictionaryTypes, SharedIntelliSense, SharedPoisonedPaths, - WriteTarget, + InstanceSource, PropertyRead, SharedDeclaredPaths, SharedDictionaryTypes, SharedIntelliSense, + SharedPoisonedPaths, WriteTarget, }; pub use decision_table::{DecisionTableDoc, DecisionTableIr, DeclaredType}; -pub(crate) use decision_table::{DictionaryCandidate, TableSelection}; +pub(crate) use decision_table::{DictionaryCandidate, TableSelection, ROW_ID_KEY}; pub use expression::{ExpressionDoc, ExpressionIr}; pub(crate) use match_block::MatchSelection; pub use match_block::{MatchDoc, MatchIr}; @@ -232,29 +231,6 @@ impl Block { ) -> Option<(Arc, ExpressionKind, VariableType)> { self.kind.resolve_cursor(cursor, scope) } - - pub fn nl( - &self, - policy_path: &Arc, - scope: &VariableType, - is: &mut IntelliSense, - dictionaries: &HashMap, VariableType>, - ) -> Vec { - self.kind.nl(policy_path, &self.id, scope, is, dictionaries) - } - - pub fn nl_scope( - &self, - cursor: &Cursor, - scope: VariableType, - is: &mut IntelliSense, - dictionaries: &HashMap, VariableType>, - ) -> (ExpressionKind, VariableType, Option) { - match &self.kind { - BlockKind::DecisionTable(d) => d.nl_scope(cursor, scope, is, dictionaries), - _ => (ExpressionKind::Standard, scope, None), - } - } } impl BlockKind { @@ -344,22 +320,6 @@ impl BlockKind { } } - pub fn nl( - &self, - policy_path: &Arc, - block_id: &Arc, - scope: &VariableType, - is: &mut IntelliSense, - dictionaries: &HashMap, VariableType>, - ) -> Vec { - match self { - BlockKind::Assertion(a) => a.nl(policy_path, block_id, scope, is), - BlockKind::DecisionTable(d) => d.nl(policy_path, block_id, scope, is, dictionaries), - BlockKind::Expression(e) => e.nl(policy_path, block_id, scope, is), - BlockKind::Match(m) => m.nl(policy_path, block_id, scope, is), - } - } - pub fn write_keys(&self) -> Vec<(Option>, Arc)> { match self { BlockKind::DecisionTable(d) => d.write_keys(), diff --git a/core/engine/src/policy/evaluator.rs b/core/engine/src/policy/evaluator.rs index f5faf4a7..b5b69520 100644 --- a/core/engine/src/policy/evaluator.rs +++ b/core/engine/src/policy/evaluator.rs @@ -570,14 +570,15 @@ impl<'a> Driver<'a> { iter_path: &Arc, owner_name: Option<&str>, ) -> Result<(), EvaluationError> { - let Some(arr) = self - .store - .dot(iter_path.as_ref()) - .and_then(|v| v.as_array()) - else { + let Some(target) = self.store.dot(iter_path.as_ref()) else { return Ok(()); }; - let instances: Vec = arr.borrow().iter().map(|v| v.shallow_clone()).collect(); + let single = target.as_object().is_some(); + let instances: Vec = match target.as_array() { + Some(arr) => arr.borrow().iter().map(|v| v.shallow_clone()).collect(), + None if single => vec![target.shallow_clone()], + None => return Ok(()), + }; let owner_binding = owner_name.and_then(|name| { let owner_path = iter_path.rsplit_once('.').map(|(o, _)| o)?; self.store @@ -664,7 +665,10 @@ impl<'a> Driver<'a> { self.executions.push(BlockExecution { block_id: rule.id.clone(), policy_path: trace_policy_path.clone(), - instance_path: Some(format!("{iter_path}.{idx}").into()), + instance_path: Some(match single { + true => iter_path.clone(), + false => format!("{iter_path}.{idx}").into(), + }), trace: bt, operand_values, writes: write_log.map(RefCell::into_inner).unwrap_or_default(), diff --git a/core/engine/src/policy/mod.rs b/core/engine/src/policy/mod.rs index f40579a0..55d11074 100644 --- a/core/engine/src/policy/mod.rs +++ b/core/engine/src/policy/mod.rs @@ -14,14 +14,15 @@ pub(crate) mod validator; pub use crate::workspace::{ BlockExecution, BlockRef, BlockTrace, Completion, ConditionTrace, ConditionalSchema, Cursor, - CursorTarget, DecisionTableExtras, DependencyNode, Diagnostic, DiagnosticCode, + CursorScope, CursorTarget, DecisionTableExtras, DependencyNode, Diagnostic, DiagnosticCode, DiagnosticLocation, Dictionary, DictionaryEntryInfo, DiscriminantVariant, DiscriminatedUnion, EngineEdit, Entity, EntityField, EvaluateRequest, EvaluationError, EvaluationResult, - ExpressionKind, FieldOrigin, FunctionResolutionRequest, FunctionTypeResolver, GraphAnalysis, - GraphNodeAnalysis, GraphSignature, GraphTraceMap, GuardedProperty, InputProperty, - InputValidationError, InspectResult, NlExpression, OutputProperty, PrepareRename, PropertyKind, - ReferenceKind, ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, - Severity, Span, Trace, Workspace, WriteConflict, WriteTrace, + ExpressionFacts, ExpressionKind, FieldOrigin, FunctionResolutionRequest, FunctionTypeResolver, + GraphAnalysis, GraphNodeAnalysis, GraphSignature, GraphTraceMap, GuardedProperty, + InputProperty, InputValidationError, InspectResult, OutputProperty, PrepareRename, + PropertyKind, ReferenceKind, ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, + ScopeRequest, Severity, SlotResponse, SlotRole, Span, Trace, Workspace, WriteConflict, + WriteTrace, }; pub use raw::{BlockDoc, PolicyDocument}; diff --git a/core/engine/src/policy/queries/dependencies.rs b/core/engine/src/policy/queries/dependencies.rs index 02cb8fb1..90c48b8f 100644 --- a/core/engine/src/policy/queries/dependencies.rs +++ b/core/engine/src/policy/queries/dependencies.rs @@ -9,9 +9,12 @@ use crate::workspace::types::{BlockRef, DependencyNode}; use zen_expression::variable::VariableType; impl Db { - pub fn dependencies(&self, target: &str) -> DependencyNode { + pub fn dependencies(&self, target: &str, policy: Option<&str>) -> DependencyNode { let snapshot = self.snapshot(); - let unit = self.unit_for_property(target); + let unit = match policy { + Some(policy) => self.unit(policy), + None => self.unit_for_property(target), + }; let entity_form = EntityForm::new(&unit.entity_sources); let enriched = self.enriched_of_unit(&unit); let scope = &enriched.scope; diff --git a/core/engine/src/policy/queries/dependency.rs b/core/engine/src/policy/queries/dependency.rs index 4cadaa00..3ccb0200 100644 --- a/core/engine/src/policy/queries/dependency.rs +++ b/core/engine/src/policy/queries/dependency.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; @@ -7,12 +8,12 @@ use petgraph::prelude::{NodeIndex, StableDiGraph}; use zen_expression::variable::VariableType; use crate::policy::blocks::{ - AnalysisContext, AnalysisSummary, Block, InstanceSource, PropertyRead, SharedDictionaryTypes, - SharedIntelliSense, SharedPoisonedPaths, WriteTarget, + AnalysisContext, AnalysisSummary, Block, InstanceSource, PropertyRead, SharedDeclaredPaths, + SharedDictionaryTypes, SharedIntelliSense, SharedPoisonedPaths, WriteTarget, }; use crate::policy::ir::{DataModelIr, ParsedPolicy, PropertyPath}; use crate::policy::queries::path::{PathClassifier, PathRoot}; -use crate::policy::queries::scope::{EntityForm, VariableTypeScope}; +use crate::policy::queries::scope::{EntityForm, EntityGraph, EntitySources, VariableTypeScope}; use crate::workspace::db::{AnalysisPass, PolicyDerivedCache, Snapshot}; use crate::workspace::types::{BlockRef, Diagnostic, DiagnosticCode, DiagnosticLocation}; @@ -57,6 +58,85 @@ pub struct EnrichedState { pub scope: VariableType, pub per_rule: Vec, pub diagnostics: Vec, + base_fields: HashMap, VariableType>, + owned: RefCell>, + write_log: Vec<(PropertyPath, VariableType)>, + own_writes: HashMap>, + dependents: HashMap>, + block_scopes: RefCell>, +} + +impl Drop for EnrichedState { + fn drop(&mut self) { + for root in self.owned.borrow().iter() { + root.break_cycles(); + } + } +} + +impl EnrichedState { + pub(crate) fn declared_at(&self, path: &str) -> VariableType { + let (root, rest) = path.split_once('.').unwrap_or((path, "")); + match self.base_fields.get(root) { + Some(kind) if rest.is_empty() => kind.shallow_clone(), + Some(kind) => kind.resolve_at(rest), + None => VariableType::Null, + } + } + + pub(crate) fn scope_excluding(&self, block: &BlockRef) -> VariableType { + if !self.own_writes.contains_key(block) { + return self.scope.shallow_clone(); + } + if let Some(cached) = self.block_scopes.borrow().get(block) { + return cached.shallow_clone(); + } + let hidden = self.dependents_closure(block); + let scope = self.scope.isolated_clone(); + self.owned.borrow_mut().push(scope.shallow_clone()); + let hidden_ranges: Vec<&std::ops::Range> = hidden + .iter() + .filter_map(|b| self.own_writes.get(b)) + .collect(); + let mut visible: HashMap<&str, &VariableType> = HashMap::new(); + let mut hidden_paths: Vec<&str> = Vec::new(); + for (i, (path, resolved_type)) in self.write_log.iter().enumerate() { + if hidden_ranges.iter().any(|r| r.contains(&i)) { + hidden_paths.push(path); + } else { + visible.insert(path, resolved_type); + } + } + for path in hidden_paths { + match visible.get(path) { + Some(resolved_type) => { + scope.insert_at_path(path, &resolved_type.isolated_clone(), true); + } + None => match self.declared_at(path) { + VariableType::Null | VariableType::Any => scope.remove_at_path(path), + declared => { + scope.insert_at_path(path, &declared.isolated_clone(), true); + } + }, + } + } + self.block_scopes + .borrow_mut() + .insert(block.clone(), scope.shallow_clone()); + scope + } + + fn dependents_closure(&self, block: &BlockRef) -> HashSet { + let mut seen: HashSet = HashSet::new(); + let mut stack = vec![block.clone()]; + while let Some(current) = stack.pop() { + if let Some(next) = self.dependents.get(¤t) { + stack.extend(next.iter().filter(|b| !seen.contains(*b)).cloned()); + } + seen.insert(current); + } + seen + } } #[derive(Debug, Clone)] @@ -71,6 +151,67 @@ pub struct DependencyGraph { pub node_map: HashMap, } +impl DependencyGraph { + pub(crate) fn link_instance_reads( + &mut self, + per_rule: &[&RuleShallowAnalysis], + entity_graph: &EntityGraph, + entity_sources: &EntitySources, + ) { + let entity_form = EntityForm::new(entity_sources); + for rule in per_rule { + let writes: Vec = rule + .writes + .iter() + .filter_map(|w| self.node_map.get(&w.path).copied()) + .collect(); + for read in &rule.reads { + let Some((prefix, entity)) = entity_graph.instance_form(&read.path, &entity_form) + else { + continue; + }; + let Some(entity_idx) = self + .node_map + .get(entity.as_str()) + .copied() + .filter(|&idx| self.graph[idx].written_by.is_some()) + else { + continue; + }; + let list = self.node_map.get(prefix).copied(); + for &target in writes.iter().chain(list.as_ref()) { + if entity_idx != target && !self.graph.contains_edge(entity_idx, target) { + self.graph.add_edge(entity_idx, target, ()); + } + } + } + } + } + + fn block_dependents(&self) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + for edge in self.graph.edge_indices() { + let Some((from, to)) = self.graph.edge_endpoints(edge) else { + continue; + }; + let (Some(writer), Some(reader)) = ( + self.graph[from].written_by.as_ref(), + self.graph[to].written_by.as_ref(), + ) else { + continue; + }; + if writer != reader + && !PathPrefix::extends(&self.graph[to].path, &self.graph[from].path) + { + out.entry(writer.clone()) + .or_default() + .insert(reader.clone()); + } + } + out + } +} + #[derive(Debug, Clone)] pub struct PropertyNode { pub path: PropertyPath, @@ -300,6 +441,7 @@ impl EvalGraph { } impl Snapshot { + #[allow(clippy::too_many_arguments)] fn analyze_block( rule: &Block, policy_path: &Arc, @@ -308,6 +450,7 @@ impl Snapshot { intellisense: &SharedIntelliSense, dictionary_types: &SharedDictionaryTypes, poisoned_paths: &SharedPoisonedPaths, + declared_paths: &SharedDeclaredPaths, ) -> AnalysisSummary { let mut ctx = AnalysisContext::new( rule_scope, @@ -317,6 +460,7 @@ impl Snapshot { pass, dictionary_types.clone(), poisoned_paths.clone(), + declared_paths.clone(), ); rule.kind.analyze(&mut ctx); ctx.finish() @@ -339,6 +483,7 @@ impl Snapshot { let no_dictionaries: SharedDictionaryTypes = Rc::new(ahash::HashMap::default()); let no_poison: SharedPoisonedPaths = Default::default(); + let no_declared: SharedDeclaredPaths = Default::default(); let policy_shallow = cache.shallow_or_compute(path, p, || { p.policy .rules() @@ -351,6 +496,7 @@ impl Snapshot { intellisense, &no_dictionaries, &no_poison, + &no_declared, ); RuleShallowAnalysis { policy_path: path.clone(), @@ -537,8 +683,10 @@ impl Snapshot { out } + #[allow(clippy::too_many_arguments)] pub(crate) fn compute_enriched( base_scope: &VariableType, + scope_roots: Rc>>, graph: &DependencyGraph, order: &[PropertyPath], rule_by_ref: &HashMap>, @@ -546,8 +694,17 @@ impl Snapshot { members: &HashSet>, intellisense: &SharedIntelliSense, dictionary_types: SharedDictionaryTypes, + declared_paths: SharedDeclaredPaths, ) -> EnrichedState { - let scope = base_scope.shallow_clone(); + let scope = base_scope.isolated_clone(); + scope_roots.borrow_mut().push(scope.shallow_clone()); + let base_fields = match base_scope { + VariableType::Object(obj) => obj.borrow().clone(), + _ => HashMap::new(), + }; + let mut write_log: Vec<(PropertyPath, VariableType)> = Vec::new(); + let mut owned: Vec = Vec::new(); + let mut own_writes: HashMap> = HashMap::new(); let mut per_rule: Vec = Vec::new(); let mut diagnostics: Vec = Vec::new(); @@ -594,6 +751,7 @@ impl Snapshot { continue; }; let policy_path = &key.policy_path; + let start = write_log.len(); let summary = Self::analyze_block( rule, policy_path, @@ -602,10 +760,22 @@ impl Snapshot { intellisense, &dictionary_types, &poisoned_paths, + &declared_paths, ); + for tw in &summary.writes { + if declared_paths.matches_prefix(&tw.path).is_none() { + let frozen = tw.resolved_type.isolated_clone(); + owned.push(frozen.shallow_clone()); + write_log.push((tw.path.clone(), frozen)); + } + } + own_writes.insert(key.clone(), start..write_log.len()); if splice { for tw in &summary.writes { + if declared_paths.matches_prefix(&tw.path).is_some() { + continue; + } if !scope.insert_at_path(&tw.path, &tw.resolved_type, true) { diagnostics.push(Diagnostic::error( DiagnosticCode::InvalidWritePath, @@ -630,6 +800,12 @@ impl Snapshot { scope, per_rule, diagnostics, + base_fields, + owned: RefCell::new(owned), + write_log, + own_writes, + dependents: graph.block_dependents(), + block_scopes: RefCell::new(HashMap::new()), } } } @@ -645,7 +821,7 @@ impl PathPrefix { } } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct DataModelPaths { all: HashSet, optional: HashSet, diff --git a/core/engine/src/policy/queries/scope.rs b/core/engine/src/policy/queries/scope.rs index 086b97b1..00113134 100644 --- a/core/engine/src/policy/queries/scope.rs +++ b/core/engine/src/policy/queries/scope.rs @@ -96,6 +96,20 @@ pub struct EntityGraph { } impl EntityGraph { + pub(crate) fn instance_form<'p>( + &self, + path: &'p str, + entity_form: &EntityForm, + ) -> Option<(&'p str, String)> { + let (prefix, rest) = path + .match_indices('.') + .map(|(dot, _)| (&path[..dot], &path[dot + 1..])) + .rev() + .find(|(prefix, _)| self.computed.contains_key(*prefix))?; + let entity = format!("{}.{rest}", self.computed[prefix].target); + Some((prefix, entity_form.rewrite(&entity).unwrap_or(entity))) + } + pub fn contains(&self, name: &str) -> bool { self.models.contains_key(name) } @@ -703,10 +717,14 @@ pub trait VariableTypeScope { fn insert_at_path(&self, path: &str, value_type: &VariableType, allow_fill: bool) -> bool; + fn remove_at_path(&self, path: &str); + fn with_dollar(&self, field_type: &VariableType) -> VariableType; fn to_acyclic(&self) -> VariableType; + fn isolated_clone(&self) -> VariableType; + fn break_cycles(&self); } @@ -760,6 +778,25 @@ impl VariableTypeScope for VariableType { true } + fn remove_at_path(&self, path: &str) { + let mut chain: Vec<(VariableType, &str)> = Vec::new(); + let mut current = self.shallow_clone(); + for segment in path.split('.') { + let object = current.unwrap_nullable().0.shallow_clone(); + if !matches!(object, VariableType::Object(_)) { + return; + } + current = object.get(segment); + chain.push((object, segment)); + } + while let Some((VariableType::Object(obj), key)) = chain.pop() { + obj.borrow_mut().remove(key); + if chain.len() < 2 || !obj.borrow().is_empty() { + break; + } + } + } + fn with_dollar(&self, field_type: &VariableType) -> VariableType { let VariableType::Object(ref obj) = self else { return self.shallow_clone(); @@ -769,6 +806,32 @@ impl VariableTypeScope for VariableType { VariableType::Object(Rc::new(RefCell::new(fields))) } + fn isolated_clone(&self) -> VariableType { + fn copy(t: &VariableType, memo: &mut HashMap<*const (), VariableType>) -> VariableType { + match t { + VariableType::Object(obj) => { + let key = Rc::as_ptr(obj) as *const (); + if let Some(cached) = memo.get(&key) { + return cached.shallow_clone(); + } + let result = VariableType::empty_object(); + memo.insert(key, result.shallow_clone()); + let VariableType::Object(fields) = &result else { + unreachable!() + }; + for (key, value) in obj.borrow().iter() { + fields.borrow_mut().insert(key.clone(), copy(value, memo)); + } + result + } + VariableType::Array(inner) => copy(inner, memo).array(), + VariableType::Nullable(inner) => VariableType::Nullable(Rc::new(copy(inner, memo))), + other => other.shallow_clone(), + } + } + copy(self, &mut HashMap::default()) + } + fn to_acyclic(&self) -> VariableType { let mut visited: HashSet<*const ()> = HashSet::default(); AcyclicCloner::clone_type(self, &mut visited) diff --git a/core/engine/src/workspace/db.rs b/core/engine/src/workspace/db.rs index bc868d8e..4f8d16b9 100644 --- a/core/engine/src/workspace/db.rs +++ b/core/engine/src/workspace/db.rs @@ -192,7 +192,8 @@ pub struct Db { function_requests: RefCell>, function_requested: RefCell>, function_resolver: RefCell>>, - scope_roots: RefCell>, + scope_roots: Rc>>, + pub(crate) labels: crate::workspace::slot::LabelCache, } impl Drop for Db { @@ -220,7 +221,8 @@ impl Db { function_requests: RefCell::new(Vec::new()), function_requested: RefCell::new(HashSet::default()), function_resolver: RefCell::new(None), - scope_roots: RefCell::new(Vec::new()), + scope_roots: Rc::new(RefCell::new(Vec::new())), + labels: Default::default(), } } @@ -537,6 +539,7 @@ impl Db { .push(base_scope.shallow_clone()); Arc::new(Snapshot::compute_enriched( &base_scope, + self.scope_roots.clone(), &unit.dep_graph, &unit.execution_order, &snap.rule_by_ref, @@ -544,6 +547,7 @@ impl Db { &unit.members, &self.intellisense, Rc::new(unit.dictionary_types()), + Rc::new(unit.data_model_paths.clone()), )) }) .clone() @@ -654,16 +658,20 @@ impl Db { let mut out = Vec::new(); ReadFlattener::extend_from_deps(&analysis.reads, &None, &mut out); out.into_iter() - .filter_map(|rd| { - if rd.unresolved { - None - } else if rd.via_alias { - entity_form - .rewrite(rd.path.as_ref()) - .map(Arc::from) - .or(Some(rd.path)) - } else { - Some(rd.path) + .filter(|rd| !rd.unresolved) + .flat_map(|rd| { + let entity = entity_form + .rewrite(rd.path.as_ref()) + .or_else(|| { + unit.entity_graph + .instance_form(rd.path.as_ref(), &entity_form) + .map(|(_, entity)| entity) + }) + .map(Arc::from); + match (rd.via_alias, entity) { + (true, Some(entity)) => vec![entity], + (false, Some(entity)) => vec![rd.path, entity], + (_, None) => vec![rd.path], } }) .collect() @@ -863,8 +871,7 @@ impl Snapshot { .iter() .flat_map(|m| shallow.rules_for(m)) .collect(); - let dep_graph = Self::compute_graph(&per_rule, &data_model_paths, &entity_sources); - let execution_order = Self::compute_execution_order(&dep_graph); + let mut dep_graph = Self::compute_graph(&per_rule, &data_model_paths, &entity_sources); let (_, pool_roots) = DataModelIr::classify_roots( subset @@ -873,6 +880,8 @@ impl Snapshot { ); let computed_instances = entity_graph.resolve_instance_targets(&dep_graph, &pool_roots); entity_graph.register_computed(&computed_instances); + dep_graph.link_instance_reads(&per_rule, &entity_graph, &entity_sources); + let execution_order = Self::compute_execution_order(&dep_graph); let mut data_models: Vec = subset .iter() diff --git a/core/engine/src/workspace/editor.rs b/core/engine/src/workspace/editor.rs index 4e41073d..15f799ea 100644 --- a/core/engine/src/workspace/editor.rs +++ b/core/engine/src/workspace/editor.rs @@ -2,44 +2,81 @@ use std::sync::Arc; use ahash::{HashMap, HashMapExt, HashSet}; use serde_json::Value; +use zen_expression::intellisense::completion::Completions; use zen_expression::intellisense::Reference; -use zen_expression::nl::NlResult; +use zen_expression::slot::SlotRole; use zen_expression::variable::VariableType; -use crate::policy::blocks::IntelliSenseSource; -use crate::policy::ir::{DataModelIr, DictionaryIr, PropertyTypeIr}; +use crate::policy::blocks::{IntelliSenseSource, ROW_ID_KEY}; +use crate::policy::ir::{DataModelIr, PropertyTypeIr}; use crate::policy::queries::scope::EntityGraph; +use crate::policy::raw::BlockDoc; use crate::workspace::db::{Db, Snapshot}; use crate::workspace::types::{ BlockRef, Completion, Cursor, CursorTarget, EngineEdit, ExpressionKind, InspectResult, - NlExpression, PrepareRename, ReferenceKind, ReferenceSite, RenameTarget, Span, SpanOps, + PrepareRename, ReferenceKind, ReferenceSite, RenameTarget, Span, SpanOps, }; impl Db { pub fn inspect(&self, cursor: &Cursor) -> Option { - let (source, _, scope) = self.resolve_cursor(cursor)?; - let r = self - .cursor_intellisense(cursor) - .borrow_mut() - .inspect(&source, cursor.pos, &scope)?; + let (source, kind, scope) = self.resolve_cursor(cursor)?; + let role = self.cursor_scope(cursor)?.role; + let r = self.cursor_intellisense(cursor).borrow_mut().inspect( + &source, + SpanOps::byte_offset(&source, cursor.pos) as u32, + matches!(kind, ExpressionKind::Unary), + role, + &scope, + )?; Some(InspectResult { - span: r.span, + span: SpanOps::char_span(&source, r.span), kind: r.kind, label: r.label, + detail: r.detail, + info: r.info, }) } pub fn completions(&self, cursor: &Cursor) -> Vec { - let Some((source, _, scope)) = self.resolve_cursor(cursor) else { + let Some((source, _, _)) = self.resolve_cursor(cursor) else { return Vec::new(); }; - let pos = cursor.pos.min(SpanOps::char_len(&source)); - self.cursor_intellisense(cursor) - .borrow_mut() - .completions(&source, pos, &scope) + let Some(scope) = self.cursor_scope(cursor) else { + return Vec::new(); + }; + const MAX_PADDING: u32 = 1024; + let len = SpanOps::char_len(&source); + let cursor_pos = match cursor.pos > len.saturating_add(MAX_PADDING) { + true => len, + false => cursor.pos, + }; + let padded; + let source: &str = if cursor_pos > len { + padded = format!("{source}{}", " ".repeat((cursor_pos - len) as usize)); + &padded + } else { + &source + }; + let pos = SpanOps::byte_offset(source, cursor_pos) as u32; + let result = self.cursor_intellisense(cursor).borrow_mut().slot( + source, + pos, + matches!(scope.kind, ExpressionKind::Unary), + scope.role, + &scope.scope, + scope.expected.as_ref(), + ); + let mut completions = Completions::from_slot(source, pos, &scope.scope, &result.slot); + if !self.is_graph(&cursor.policy_path) { + completions.retain(|c| c.label != "$root"); + } + completions } - fn cursor_intellisense(&self, cursor: &Cursor) -> crate::policy::blocks::SharedIntelliSense { + pub(crate) fn cursor_intellisense( + &self, + cursor: &Cursor, + ) -> crate::policy::blocks::SharedIntelliSense { if self.is_graph(&cursor.policy_path) { self.graph_intellisense() } else { @@ -47,105 +84,6 @@ impl Db { } } - pub fn nl(&self, policy: &str) -> Vec { - if self.is_graph(policy) { - return self.graph_nl(policy); - } - let policy_arc: Arc = Arc::from(policy); - let Some(parsed) = self.parsed(&policy_arc) else { - return Vec::new(); - }; - let scope = self.enriched(policy).scope.shallow_clone(); - let dictionaries = self.unit(policy).dictionary_types(); - let labels = self.nl_label_resolver(policy); - let intellisense = self.intellisense(); - let mut is = intellisense.borrow_mut(); - is.set_nl_labels(labels); - let mut out = Vec::new(); - for rule in parsed.policy.rules() { - out.extend(rule.nl(&policy_arc, &scope, &mut is, &dictionaries)); - } - is.set_nl_labels(None); - out - } - - pub fn nl_tokenize(&self, cursor: &Cursor, text: &str) -> Option { - let (kind, scope, expected) = self.nl_scope(cursor)?; - let unary = matches!(kind, ExpressionKind::Unary); - let labels = self.nl_label_resolver(&cursor.policy_path); - let intellisense = self.cursor_intellisense(cursor); - let mut is = intellisense.borrow_mut(); - is.set_nl_labels(labels); - let mut result = - is.nl_tokenize_scoped(&cursor.block_id, text, unary, &scope, expected.as_ref()); - if unary { - let subject = scope.get("$"); - result.subject_options = is.nl_subject_options(&subject); - result.subject_type = Some(subject); - } else if let Some(expected) = &expected { - result.subject_options = is.nl_subject_options(expected); - result.subject_type = Some(expected.shallow_clone()); - } - is.set_nl_labels(None); - Some(result) - } - - pub(crate) fn nl_label_resolver( - &self, - policy: &str, - ) -> Option { - let mut labels: HashMap, HashMap, Arc>> = HashMap::new(); - let mut add = |name: Arc, dict: &DictionaryIr| { - let entries: HashMap, Arc> = dict - .entries - .iter() - .filter(|e| !e.label.is_empty()) - .map(|e| (e.value.clone(), e.label.clone())) - .collect(); - if !entries.is_empty() { - labels.insert(name, entries); - } - }; - if self.is_graph(policy) { - for entry in self.graph_dictionary_blocks(&self.graph_imports(policy)) { - add(entry.ir.name.clone(), entry.ir.as_ref()); - } - } else { - let unit = self.unit(policy); - for (name, dict) in &unit.dictionaries { - add(name.clone(), dict.as_ref()); - } - } - if labels.is_empty() { - return None; - } - Some(std::rc::Rc::new(move |name: &str, value: &str| { - labels.get(name)?.get(value).map(|l| l.to_string()) - })) - } - - fn nl_scope( - &self, - cursor: &Cursor, - ) -> Option<(ExpressionKind, VariableType, Option)> { - if self.is_graph(&cursor.policy_path) { - let (_, kind, scope) = self.graph_resolve_cursor(cursor)?; - let expected = (!matches!(kind, ExpressionKind::Unary)) - .then(|| self.graph_cell_expected(cursor)) - .flatten(); - return Some((kind, scope, expected)); - } - let block = self.block_ir(&BlockRef { - policy_path: cursor.policy_path.clone(), - block_id: cursor.block_id.clone(), - })?; - let scope = self.enriched(&cursor.policy_path).scope.shallow_clone(); - let dictionaries = self.unit(&cursor.policy_path).dictionary_types(); - let intellisense = self.intellisense(); - let mut is = intellisense.borrow_mut(); - Some(block.nl_scope(cursor, scope, &mut is, &dictionaries)) - } - pub fn prepare_rename(&self, cursor: &Cursor) -> Option { if self.is_graph(&cursor.policy_path) { return self.graph_prepare_rename(cursor); @@ -180,25 +118,37 @@ impl Db { } pub fn rename(&self, target: &RenameTarget, new_name: &str) -> Vec { + self.rename_from(target, new_name, None) + } + + pub fn rename_from( + &self, + target: &RenameTarget, + new_name: &str, + origin: Option<&str>, + ) -> Vec { if let RenameTarget::GraphProperty { document, path } = target { return self.graph_rename(document, path, new_name); } if let RenameTarget::GraphNode { document, node_id } = target { return self.graph_node_rename(document, node_id, new_name); } + let (sites, scope) = self.scoped_policy_sites(target, origin); let mut per_block: HashMap> = HashMap::new(); - self.walk_renamable(target, |site| { + for site in sites { let key = BlockRef { policy_path: site.policy_path.clone(), block_id: site.block_id.clone(), }; per_block.entry(key).or_default().push(site); - }); + } let mut edits: Vec = per_block .into_iter() .filter_map(|(block_ref, sites)| self.build_replace_block(block_ref, sites, new_name)) .collect(); - edits.extend(self.replace_node_edits(self.policy_caller_sites(target), new_name)); + edits.extend( + self.replace_node_edits(self.policy_caller_sites(target, scope.as_ref()), new_name), + ); edits } @@ -222,18 +172,27 @@ impl Db { } pub fn references(&self, target: &RenameTarget) -> Vec { + self.references_from(target, None) + } + + pub fn references_from( + &self, + target: &RenameTarget, + origin: Option<&str>, + ) -> Vec { if let RenameTarget::GraphProperty { document, path } = target { return self.graph_references(document, path); } if let RenameTarget::GraphNode { document, node_id } = target { return self.graph_node_references(document, node_id); } - let mut sites = Vec::new(); - self.walk_renamable(target, |site| { - sites.push(site.into_reference()); - }); + let (policy_sites, scope) = self.scoped_policy_sites(target, origin); + let mut sites: Vec = policy_sites + .into_iter() + .map(RenameSite::into_reference) + .collect(); sites.extend( - self.policy_caller_sites(target) + self.policy_caller_sites(target, scope.as_ref()) .into_iter() .map(RenameSite::into_reference), ); @@ -241,6 +200,52 @@ impl Db { sites } + fn scoped_policy_sites( + &self, + target: &RenameTarget, + origin: Option<&str>, + ) -> (Vec, Option>>) { + let mut sites = Vec::new(); + self.walk_renamable(target, |site| sites.push(site)); + let Some(origin) = origin.filter(|o| self.parsed(&Arc::from(*o)).is_some()) else { + return (sites, None); + }; + let mentioning: HashSet> = sites.iter().map(|s| s.policy_path.clone()).collect(); + let scope = self.policy_rename_scope(&Arc::from(origin), &mentioning); + sites.retain(|s| scope.contains(&s.policy_path)); + (sites, Some(scope)) + } + + fn policy_rename_scope( + &self, + origin: &Arc, + mentioning: &HashSet>, + ) -> HashSet> { + let closure = |policy: &Arc| self.unit(policy).members.clone(); + let closures: HashMap, HashSet>> = mentioning + .iter() + .chain(std::iter::once(origin)) + .map(|p| (p.clone(), closure(p))) + .collect(); + let mut scope: HashSet> = HashSet::default(); + scope.insert(origin.clone()); + let mut queue = vec![origin.clone()]; + while let Some(current) = queue.pop() { + for other in mentioning { + if scope.contains(other) { + continue; + } + let linked = + closures[¤t].contains(other) || closures[other].contains(¤t); + if linked { + scope.insert(other.clone()); + queue.push(other.clone()); + } + } + } + scope + } + fn prepare_rename_data_model(&self, cursor: &Cursor) -> Option { let parsed = self.parsed(&cursor.policy_path)?; let dm: &DataModelIr = parsed @@ -288,14 +293,70 @@ impl Db { if self.is_graph(&cursor.policy_path) { return self.graph_resolve_cursor(cursor); } - let rule = self.block_ir(&BlockRef { + let block_ref = BlockRef { policy_path: cursor.policy_path.clone(), block_id: cursor.block_id.clone(), - })?; - rule.resolve_cursor( - cursor, - self.enriched(&cursor.policy_path).scope.shallow_clone(), - ) + }; + let mut scope = self.cursor_scope(cursor)?; + if matches!(scope.role, SlotRole::Path) { + scope.scope = self.enriched(&cursor.policy_path).scope.shallow_clone(); + } + let policy = self.raw_policy(&cursor.policy_path)?; + let block = policy + .blocks + .iter() + .find(|b| b.id().is_some_and(|id| id == cursor.block_id.as_ref()))?; + let source = match (block, &cursor.target) { + (BlockDoc::Expression { data, .. }, CursorTarget::Expression { .. }) => { + data.value.clone() + } + (BlockDoc::Expression { data, .. }, CursorTarget::ExpressionKey { .. }) => { + data.key.clone() + } + (BlockDoc::Assertion { data, .. }, CursorTarget::Expression { id }) => data + .conditions + .iter() + .find(|c| c.id == *id)? + .expression + .clone(), + (BlockDoc::Assertion { data, .. }, CursorTarget::AssertionOutput) => { + data.output.clone() + } + (BlockDoc::Match { data, .. }, CursorTarget::MatchTarget) => data.key.clone(), + (BlockDoc::Match { data, .. }, CursorTarget::MatchValue { id }) => { + data.arms.iter().find(|a| a.id == *id)?.value.clone() + } + (BlockDoc::Match { data, .. }, CursorTarget::Expression { id }) => { + data.arms.iter().find(|a| a.id == *id)?.condition.clone() + } + ( + BlockDoc::DecisionTable { data, .. }, + CursorTarget::DecisionTableCell { row, col }, + ) => data + .rules + .iter() + .find(|r| r.get(ROW_ID_KEY) == Some(row))? + .get(col) + .cloned() + .unwrap_or_else(|| Arc::from("")), + (BlockDoc::DecisionTable { data, .. }, CursorTarget::DecisionTableHead { col }) => { + if let Some(input) = data.inputs.iter().find(|c| c.id == *col) { + input.field.clone().unwrap_or_else(|| Arc::from("")) + } else { + let field = &data.outputs.iter().find(|c| c.id == *col)?.field; + match field.strip_suffix("[]") { + Some(base) => Arc::from(base.trim_end()), + None => field.clone(), + } + } + } + _ => { + return self + .block_ir(&block_ref)? + .resolve_cursor(cursor, scope.scope); + } + }; + Some((source, scope.kind, scope.scope)) } } @@ -515,10 +576,8 @@ impl EntityGraph { name: first.clone(), }, ); - match self.next_entity_for_global(&first) { - Some(target) => Some((target, 1)), - None => None, - } + self.next_entity_for_global(&first) + .map(|target| (target, 1)) } else { None } diff --git a/core/engine/src/workspace/graph/analysis.rs b/core/engine/src/workspace/graph/analysis.rs index 4b6bfefc..5c152a24 100644 --- a/core/engine/src/workspace/graph/analysis.rs +++ b/core/engine/src/workspace/graph/analysis.rs @@ -6,8 +6,9 @@ use ahash::{HashMap, HashMapExt, HashSet}; use zen_expression::variable::VariableType; use zen_types::decision::{ DecisionNode, DecisionNodeContent, DecisionNodeKind, DecisionTableContent, - DecisionTableHitPolicy, DecisionTableOutputField, ExpressionNodeContent, FunctionNodeContent, - SwitchNodeContent, SwitchStatementHitPolicy, TransformAttributes, TransformExecutionMode, + DecisionTableHitPolicy, DecisionTableOutputField, Expression, ExpressionNodeContent, + FunctionNodeContent, SwitchNodeContent, SwitchStatementHitPolicy, TransformAttributes, + TransformExecutionMode, }; use zen_expression::intellisense::ArmTest; @@ -21,7 +22,8 @@ use crate::policy::queries::scope::VariableTypeScope; use crate::workspace::db::Db; use crate::workspace::graph::function::FunctionTypeOutcome; use crate::workspace::types::{ - CursorTarget, Diagnostic, DiagnosticCode, DiagnosticLocation, ExpressionKind, Severity, + CursorTarget, Diagnostic, DiagnosticArgs, DiagnosticCode, DiagnosticLocation, ExpressionKind, + Severity, }; const NODES_KEY: &str = "$nodes"; @@ -37,7 +39,7 @@ pub struct GraphNodeAnalysis { pub input: VariableType, pub handler_input: VariableType, pub output: VariableType, - pub dollar: Option, + pub row_types: HashMap, VariableType>, pub nodes_scope: VariableType, pub branch_outputs: HashMap, VariableType>, pub opaque: bool, @@ -45,6 +47,18 @@ pub struct GraphNodeAnalysis { pub open: bool, } +impl GraphNodeAnalysis { + pub(crate) fn dollar_before(&self, rows: &[Expression], row_id: &str) -> VariableType { + let dollar = VariableType::empty_object(); + for row in rows.iter().take_while(|row| row.id.as_ref() != row_id) { + if let Some(resolved) = self.row_types.get(&row.id) { + dollar.insert_at_path(&row.key, resolved, true); + } + } + dollar + } +} + #[derive(Debug)] pub struct GraphAnalysis { pub diagnostics: Vec, @@ -72,6 +86,7 @@ pub(crate) struct GraphAnalyzer<'a> { content: &'a GraphContent, diagnostics: Vec, validate: bool, + unchecked: bool, nodes_scope: VariableType, dictionary_types: HashMap, VariableType>, } @@ -94,6 +109,7 @@ impl<'a> GraphAnalyzer<'a> { content, diagnostics: Vec::new(), validate: false, + unchecked: false, nodes_scope: VariableType::Any, dictionary_types, } @@ -139,12 +155,10 @@ impl<'a> GraphAnalyzer<'a> { self.lint_expressions(); self.sort_diagnostics(&topology); + let input = self.graph_input_signature(); GraphAnalysis { diagnostics: self.diagnostics, - signature: GraphSignature { - input: graph_input, - output, - }, + signature: GraphSignature { input, output }, nodes, inferred_inputs, } @@ -397,6 +411,18 @@ impl<'a> GraphAnalyzer<'a> { .unwrap_or(VariableType::Any) } + fn graph_input_signature(&self) -> VariableType { + self.content + .nodes + .iter() + .find_map(|node| match &node.kind { + DecisionNodeKind::InputNode { content } => content.schema.as_ref(), + _ => None, + }) + .map(|schema| super::SchemaType::variable_type_with(schema, &self.dictionary_types)) + .unwrap_or(VariableType::Any) + } + fn check_schema_dictionaries(&mut self, node: &DecisionNode, schema: &serde_json::Value) { let mut names: Vec> = Vec::new(); super::SchemaType::dictionary_names(schema, &mut names); @@ -437,18 +463,19 @@ impl<'a> GraphAnalyzer<'a> { open: bool, graph_input: &VariableType, ) -> GraphNodeAnalysis { - let scope_input = if unchecked || matches!(input, VariableType::Any) { + let scope_input = if matches!(input, VariableType::Any) { VariableType::empty_object() } else { input.shallow_clone() }; self.validate = !unchecked && !open && !matches!(input, VariableType::Any); + self.unchecked = unchecked; let mut analysis = GraphNodeAnalysis { input: scope_input.shallow_clone(), handler_input: scope_input.shallow_clone(), output: VariableType::Any, - dollar: None, + row_types: HashMap::default(), nodes_scope: self.nodes_scope.shallow_clone(), branch_outputs: HashMap::default(), opaque: false, @@ -534,8 +561,9 @@ impl<'a> GraphAnalyzer<'a> { &content.transform_attributes, &scope_input, |analyzer, scope| { - let (output, dollar) = analyzer.check_expression_rows(node, content, scope); - analysis.dollar = Some(dollar); + let (output, row_types) = + analyzer.check_expression_rows(node, content, scope); + analysis.row_types = row_types; output }, ); @@ -700,7 +728,7 @@ impl<'a> GraphAnalyzer<'a> { let element = match base.iterator() { Some(inner) => inner.as_ref().shallow_clone(), None => { - if !matches!(base, VariableType::Any) { + if !self.unchecked && !matches!(base, VariableType::Any) { self.diagnostics.push(Diagnostic::error( DiagnosticCode::TypeMismatch, DiagnosticLocation::block(self.path.clone(), node.id.clone()) @@ -768,9 +796,10 @@ impl<'a> GraphAnalyzer<'a> { node: &DecisionNode, content: &ExpressionNodeContent, scope: &VariableType, - ) -> (VariableType, VariableType) { + ) -> (VariableType, HashMap, VariableType>) { let output = VariableType::empty_object(); let dollar = VariableType::empty_object(); + let mut row_types = HashMap::with_capacity(content.expressions.len()); for row in content.expressions.iter() { if row.key.is_empty() || row.value.is_empty() { continue; @@ -792,8 +821,9 @@ impl<'a> GraphAnalyzer<'a> { ); output.insert_at_path(&row.key, &resolved, true); dollar.insert_at_path(&row.key, &resolved, true); + row_types.insert(row.id.clone(), resolved); } - (output, dollar) + (output, row_types) } fn check_decision_table( @@ -854,18 +884,28 @@ impl<'a> GraphAnalyzer<'a> { ExpressionKind::Standard, &base_scope, ); - if !matches!(resolved, VariableType::Bool | VariableType::Any) { - self.diagnostics.push(Diagnostic::error( - DiagnosticCode::TypeMismatch, - DiagnosticLocation::expression( - self.path.clone(), - node.id.clone(), - col.id.clone(), - None, + if !self.unchecked + && !matches!(resolved, VariableType::Bool | VariableType::Any) + { + self.diagnostics.push( + Diagnostic::error( + DiagnosticCode::TypeMismatch, + DiagnosticLocation::expression( + self.path.clone(), + node.id.clone(), + col.id.clone(), + None, + ) + .with_target(target), + format!( + "input condition must return a boolean, got `{resolved}`" + ), ) - .with_target(target), - format!("input condition must return a boolean, got `{resolved}`"), - )); + .with_expr_code( + "type.condition-not-bool", + DiagnosticArgs::from([("got", resolved.to_string())]), + ), + ); } } } @@ -1029,7 +1069,8 @@ impl<'a> GraphAnalyzer<'a> { if !collect && (has_empty_cell || (has_null_cell && declared.is_some())) { merged = super::wrap_optional(merged); } - if declared.is_none() + if !self.unchecked + && declared.is_none() && matches!(merged, VariableType::Any) && cell_types.len() > 1 && !cell_types.iter().any(|t| matches!(t, VariableType::Any)) @@ -1403,17 +1444,23 @@ impl<'a> GraphAnalyzer<'a> { ExpressionKind::Standard, &condition_scope, ); - if !matches!(resolved, VariableType::Bool | VariableType::Any) { - self.diagnostics.push(Diagnostic::error( - DiagnosticCode::TypeMismatch, - DiagnosticLocation::expression( - self.path.clone(), - node.id.clone(), - statement.id.clone(), - None, + if !self.unchecked && !matches!(resolved, VariableType::Bool | VariableType::Any) { + self.diagnostics.push( + Diagnostic::error( + DiagnosticCode::TypeMismatch, + DiagnosticLocation::expression( + self.path.clone(), + node.id.clone(), + statement.id.clone(), + None, + ), + format!("switch condition must return a boolean, got `{resolved}`"), + ) + .with_expr_code( + "type.condition-not-bool", + DiagnosticArgs::from([("got", resolved.to_string())]), ), - format!("switch condition must return a boolean, got `{resolved}`"), - )); + ); } let intellisense = self.db.graph_intellisense(); let mut is = intellisense.borrow_mut(); diff --git a/core/engine/src/workspace/graph/cursor.rs b/core/engine/src/workspace/graph/cursor.rs index 2df8bd4e..1b4e2dbd 100644 --- a/core/engine/src/workspace/graph/cursor.rs +++ b/core/engine/src/workspace/graph/cursor.rs @@ -31,26 +31,44 @@ impl Db { ) -> Option<(Arc, ExpressionKind, VariableType)> { if matches!(cursor.target, CursorTarget::TransformInput) { let attributes = super::editor::NodePaths::attributes(node)?; - let field = attributes.input_field.as_ref()?; + let field = attributes + .input_field + .clone() + .unwrap_or_else(|| Arc::from("")); let scope = GraphAnalyzer::scope_with_nodes(&node_analysis.input, &node_analysis.nodes_scope); - return Some((field.clone(), ExpressionKind::Standard, scope)); + return Some((field, ExpressionKind::Standard, scope)); } match &node.kind { DecisionNodeKind::ExpressionNode { content } => { + if let CursorTarget::ExpressionKey { id } = &cursor.target { + let key = id + .as_ref() + .and_then(|id| content.expressions.iter().find(|row| row.id == *id)) + .map(|row| row.key.clone()); + let scope = GraphAnalyzer::scope_with_nodes( + if key.is_some() { + &node_analysis.output + } else { + &node_analysis.input + }, + &node_analysis.nodes_scope, + ); + return Some(( + key.unwrap_or_else(|| Arc::from("")), + ExpressionKind::Standard, + scope, + )); + } let CursorTarget::Expression { id } = &cursor.target else { return None; }; let row = content.expressions.iter().find(|row| row.id == *id)?; - let dollar = node_analysis - .dollar - .clone() - .unwrap_or_else(VariableType::empty_object); let scope = GraphAnalyzer::scope_with( &node_analysis.handler_input, &[ - ("$", dollar), + ("$", node_analysis.dollar_before(&content.expressions, id)), ("$nodes", node_analysis.nodes_scope.shallow_clone()), ], ); @@ -86,9 +104,12 @@ impl Db { ); match &cursor.target { CursorTarget::DecisionTableHead { col } => { - let column = content.inputs.iter().find(|c| c.id == *col)?; - let field = column.field.as_ref()?; - Some((field.clone(), ExpressionKind::Standard, base_scope)) + let field = if let Some(column) = content.inputs.iter().find(|c| c.id == *col) { + column.field.clone().unwrap_or_else(|| Arc::from("")) + } else { + content.outputs.iter().find(|c| c.id == *col)?.field.clone() + }; + Some((field, ExpressionKind::Standard, base_scope)) } CursorTarget::DecisionTableCell { row, col } => { let rule = content diff --git a/core/engine/src/workspace/graph/dependencies.rs b/core/engine/src/workspace/graph/dependencies.rs index 5e4bd775..c06e26b4 100644 --- a/core/engine/src/workspace/graph/dependencies.rs +++ b/core/engine/src/workspace/graph/dependencies.rs @@ -160,6 +160,7 @@ impl Db { return Vec::new(); } let analysis = self.graph_analysis(document); + let declared = self.graph_schema_declaration(document, target).is_some(); let mut hits: Vec = Vec::new(); for node in &content.nodes { @@ -173,6 +174,7 @@ impl Db { match &node.kind { DecisionNodeKind::ExpressionNode { content } => { let mut row_ids: Vec> = Vec::new(); + let mut whole = prefix_covers; if let Some(local) = paths.local_write_target(&target_segments) { for row in content.expressions.iter() { if row.key.is_empty() { @@ -180,10 +182,11 @@ impl Db { } if PathMatch::key_overlaps(&row.key, local) { row_ids.push(row.id.clone()); + whole |= PathMatch::key_writes_whole(&row.key, local); } } } - if !row_ids.is_empty() || prefix_covers { + if (!row_ids.is_empty() || prefix_covers) && (whole || !declared) { let filter = (!prefix_covers).then_some(row_ids); let reads = if deep { self.node_global_reads(node, &paths, filter.as_deref()) @@ -199,16 +202,18 @@ impl Db { } } DecisionNodeKind::DecisionTableNode { content } => { - let writes = prefix_covers - || paths - .local_write_target(&target_segments) - .is_some_and(|local| { - content.outputs.iter().any(|col| { - let (path, _) = col.write_path(); - !path.is_empty() && PathMatch::key_overlaps(path, local) - }) - }); - if writes { + let local = paths.local_write_target(&target_segments); + let written_by = |matches: fn(&str, &[&str]) -> bool| { + local.is_some_and(|local| { + content.outputs.iter().any(|col| { + let (path, _) = col.write_path(); + !path.is_empty() && matches(path, local) + }) + }) + }; + let writes = prefix_covers || written_by(PathMatch::key_overlaps); + let whole = prefix_covers || written_by(PathMatch::key_writes_whole); + if writes && (whole || !declared) { let reads = if deep { self.node_global_reads(node, &paths, None) } else { @@ -336,7 +341,7 @@ impl Db { return None; } let nested = if deep && !local.is_empty() { - let child = self.dependencies(&local_joined); + let child = self.dependencies(&local_joined, Some(&callee)); if child.written_by.is_some() { child.deps } else { diff --git a/core/engine/src/workspace/graph/editor.rs b/core/engine/src/workspace/graph/editor.rs index dd91a03e..36d551c4 100644 --- a/core/engine/src/workspace/graph/editor.rs +++ b/core/engine/src/workspace/graph/editor.rs @@ -127,6 +127,12 @@ impl PathMatch { && segments.iter().zip(local_target).all(|(seg, t)| seg == t) } + pub(crate) fn key_writes_whole(key: &str, local_target: &[&str]) -> bool { + let segments: Vec<&str> = key.split('.').collect(); + segments.len() <= local_target.len() + && segments.iter().zip(local_target).all(|(seg, t)| seg == t) + } + pub(crate) fn key_overlaps(key: &str, local_target: &[&str]) -> bool { Self::key_matches(key, local_target).is_some() || Self::key_covers(key, local_target) } @@ -139,6 +145,15 @@ impl Db { let doc = snap.graphs.get(&cursor.policy_path)?.clone(); let content = doc.as_graph()?; let node = content.nodes.iter().find(|n| n.id == cursor.block_id)?; + if let ( + CursorTarget::DecisionTableHead { col }, + DecisionNodeKind::DecisionTableNode { content }, + ) = (&cursor.target, &node.kind) + { + if let Some(column) = content.outputs.iter().find(|c| c.id == *col) { + return self.graph_prepare_output_rename(cursor, node, &column.field); + } + } let paths = NodePaths::new(node); let dollar_local = matches!(node.kind, DecisionNodeKind::ExpressionNode { .. }) && matches!(cursor.target, CursorTarget::Expression { .. }); @@ -185,14 +200,43 @@ impl Db { } let path: Arc = Arc::from(global.join(".")); let mut visited: HashSet> = HashSet::new(); - let target = - self.graph_resolve_property_target(&cursor.policy_path, &path, &mut visited)?; + let target = self + .graph_resolve_property_target(&cursor.policy_path, &path, &mut visited) + .or_else(|| self.graph_input_property_target(&cursor.policy_path, &path))?; return Some(PrepareRename { target, span }); } } None } + fn graph_prepare_output_rename( + &self, + cursor: &Cursor, + node: &DecisionNode, + field: &str, + ) -> Option { + let field = field.strip_suffix("[]").map(str::trim_end).unwrap_or(field); + let segments: Vec<&str> = field.split('.').collect(); + let index = (0..segments.len()).find(|&i| { + let span = PathMatch::segment_span(field, i); + span.0 <= cursor.pos && cursor.pos <= span.1 + })?; + if segments[..=index].iter().any(|segment| segment.is_empty()) { + return None; + } + let paths = NodePaths::new(node); + let mut global: Vec<&str> = paths.output_prefix.iter().map(String::as_str).collect(); + global.extend(&segments[..=index]); + let path: Arc = Arc::from(global.join(".")); + let mut visited: HashSet> = HashSet::new(); + let target = + self.graph_resolve_property_target(&cursor.policy_path, &path, &mut visited)?; + Some(PrepareRename { + target, + span: PathMatch::segment_span(field, index), + }) + } + pub(crate) fn graph_references( &self, document: &Arc, @@ -204,15 +248,17 @@ impl Db { .map(RenameSite::into_reference) .map(Self::clip_reference_line) .collect(); - if let Some((node_id, key)) = self.graph_schema_declaration(document, path) { - sites.push(ReferenceSite { - policy_path: document.clone(), - block_id: node_id, - expression_id: None, - source: key.clone(), - span: (0, SpanOps::char_len(&key)), - kind: ReferenceKind::DataModel, - }); + for (doc, doc_path) in self.graph_property_chain(document, path) { + if let Some((node_id, key)) = self.graph_schema_declaration(&doc, &doc_path) { + sites.push(ReferenceSite { + policy_path: doc, + block_id: node_id, + expression_id: None, + source: key.clone(), + span: (0, SpanOps::char_len(&key)), + kind: ReferenceKind::DataModel, + }); + } } sites.sort_by(ReferenceSite::display_cmp); sites @@ -224,12 +270,19 @@ impl Db { path: &Arc, new_name: &str, ) -> Vec { - let mut edits = self.replace_node_edits(self.graph_rename_sites(document, path), new_name); - edits.extend(self.graph_schema_rename_edit(document, path, new_name)); + let chain = self.graph_property_chain(document, path); + let sites = chain + .iter() + .flat_map(|(doc, doc_path)| self.graph_collect_sites(doc, doc_path)) + .collect(); + let mut edits = self.replace_node_edits(sites, new_name); + for (doc, doc_path) in &chain { + edits.extend(self.graph_schema_rename_edit(doc, doc_path, new_name)); + } edits } - fn graph_schema_declaration( + pub(crate) fn graph_schema_declaration( &self, document: &Arc, path: &str, @@ -513,10 +566,9 @@ impl Db { { if let Some(&span) = reference.spans.get(1) { let span = SpanOps::char_span(&site.source, span); - let quoted = site - .source + let quoted = site.source[SpanOps::byte_offset(&site.source, span.0)..] .chars() - .nth(span.0 as usize) + .next() .is_some_and(|c| c == '\'' || c == '"'); if !quoted { push(span); @@ -534,6 +586,7 @@ impl Db { fn bracket_name_spans(source: &str, name: &str) -> Vec { let chars: Vec = source.chars().collect(); + let units = utf16_prefix(&chars); let name_chars: Vec = name.chars().collect(); let needle: Vec = "$nodes[".chars().collect(); let mut out: Vec = Vec::new(); @@ -560,7 +613,7 @@ impl Db { && chars[start..end] == name_chars[..] && chars.get(end) == Some("e) { - out.push((start as u32, end as u32)); + out.push((units[start], units[end])); } i = j + 1; } @@ -572,7 +625,8 @@ impl Db { return site; } let chars: Vec = site.source.chars().collect(); - let span_start = site.span.0 as usize; + let units = utf16_prefix(&chars); + let span_start = units.partition_point(|&u| u < site.span.0); let line_start = chars[..span_start.min(chars.len())] .iter() .rposition(|&c| c == '\n') @@ -583,7 +637,7 @@ impl Db { .map_or(chars.len(), |at| span_start + at); let line: String = chars[line_start..line_end].iter().collect(); let lead = line.chars().take_while(|c| c.is_whitespace()).count() as u32; - let shift = (line_start as u32 + lead).min(site.span.0); + let shift = (units[line_start.min(chars.len())] + lead).min(site.span.0); ReferenceSite { source: Arc::from(line.trim_start()), span: (site.span.0 - shift, site.span.1 - shift), @@ -592,9 +646,105 @@ impl Db { } fn graph_rename_sites(&self, document: &Arc, path: &Arc) -> Vec { - let mut sites = self.graph_collect_sites(document, path); - self.extend_with_caller_reads(vec![(document.clone(), path.clone())], &mut sites); - sites + self.graph_property_chain(document, path) + .iter() + .flat_map(|(doc, doc_path)| self.graph_collect_sites(doc, doc_path)) + .collect() + } + + fn graph_property_chain( + &self, + document: &Arc, + path: &Arc, + ) -> Vec<(Arc, Arc)> { + let snap = self.snapshot(); + let mut out: Vec<(Arc, Arc)> = vec![(document.clone(), path.clone())]; + let mut visited: HashSet<(Arc, Arc)> = out.iter().cloned().collect(); + let mut queue: VecDeque<(Arc, Arc)> = + VecDeque::from([(document.clone(), path.clone())]); + while let Some((doc_path, doc_prop)) = queue.pop_front() { + let mut next: Vec<(Arc, Arc)> = Vec::new(); + if let Some(content) = snap.graphs.get(&doc_path).and_then(|d| d.as_graph()) { + let target: Vec<&str> = doc_prop.split('.').collect(); + for node in &content.nodes { + let DecisionNodeKind::DecisionNode { content: reference } = &node.kind else { + continue; + }; + if !snap.graphs.contains_key(&reference.key) { + continue; + } + let Some(local) = NodePaths::new(node).local_read_target(&target) else { + continue; + }; + if local.is_empty() { + continue; + } + let callee_path: Arc = Arc::from(local.join(".")); + if self.graph_takes_input(&reference.key, &callee_path) { + next.push((reference.key.clone(), callee_path)); + } + } + } + if self.graph_takes_input(&doc_path, &doc_prop) { + next.extend(self.graph_input_callers(&doc_path, &doc_prop)); + } + for (caller, caller_path) in self.graph_caller_paths(&doc_path, &doc_prop) { + let overwritten = self + .graph_collect_sites(&caller, &caller_path) + .iter() + .any(|s| s.kind == ReferenceKind::WriteKey); + if !overwritten { + next.push((caller, caller_path)); + } + } + for item in next { + if visited.insert(item.clone()) { + out.push(item.clone()); + queue.push_back(item); + } + } + } + out + } + + fn graph_takes_input(&self, document: &Arc, path: &str) -> bool { + if self.graph_schema_declaration(document, path).is_some() { + return true; + } + let sites = self.graph_collect_sites(document, path); + !sites.is_empty() && !sites.iter().any(|s| s.kind == ReferenceKind::WriteKey) + } + + fn graph_input_callers(&self, callee: &str, callee_path: &str) -> Vec<(Arc, Arc)> { + let snap = self.snapshot(); + let mut out: Vec<(Arc, Arc)> = Vec::new(); + for (doc_path, doc) in snap.graphs.iter() { + if doc_path.as_ref() == callee { + continue; + } + let Some(content) = doc.as_graph() else { + continue; + }; + for node in &content.nodes { + let DecisionNodeKind::DecisionNode { content: reference } = &node.kind else { + continue; + }; + if reference.key.as_ref() != callee { + continue; + } + let caller_path: Arc = match NodePaths::new(node).read_base { + ReadBase::NodeInput => Arc::from(callee_path), + ReadBase::Prefixed(prefix) => { + Arc::from(format!("{}.{}", prefix.join("."), callee_path)) + } + ReadBase::Opaque => continue, + }; + out.push((doc_path.clone(), caller_path)); + } + } + out.sort(); + out.dedup(); + out } fn extend_with_caller_reads( @@ -715,6 +865,19 @@ impl Db { None } + fn graph_input_property_target( + &self, + document: &Arc, + path: &Arc, + ) -> Option { + let declared = self.graph_schema_declaration(document, path).is_some(); + let read = !self.graph_collect_sites(document, path).is_empty(); + (declared || read).then(|| RenameTarget::GraphProperty { + document: document.clone(), + path: path.clone(), + }) + } + fn policy_property_target(&self, policy: &Arc, local: &[&str]) -> Option { use crate::workspace::types::ScopeRequest; let req = ScopeRequest::for_policy(policy.clone()); @@ -746,7 +909,11 @@ impl Db { } } - pub(crate) fn policy_caller_sites(&self, target: &RenameTarget) -> Vec { + pub(crate) fn policy_caller_sites( + &self, + target: &RenameTarget, + scope: Option<&HashSet>>, + ) -> Vec { let segments: Vec> = match target { RenameTarget::Entity { name } => vec![name.clone()], RenameTarget::Field { entity, field } => vec![entity.clone(), field.clone()], @@ -778,6 +945,9 @@ impl Db { continue; } let callee: Arc = Arc::from(reference.key.as_ref()); + if scope.is_some_and(|scope| !scope.contains(&callee)) { + continue; + } if !self.policy_exposes(&callee, target) { continue; } @@ -894,6 +1064,16 @@ impl Db { return Vec::new(); } + let exposing: HashSet<&str> = content + .nodes + .iter() + .filter(|node| { + matches!(node.kind, DecisionNodeKind::InputNode { .. }) + || NodePaths::attributes(node).is_some_and(|a| a.pass_through) + }) + .map(|node| node.name.as_ref()) + .collect(); + let mut sites: Vec = Vec::new(); let mut emit = |node_id: &Arc, expression_id: Option>, @@ -968,6 +1148,23 @@ impl Db { continue; } let segments: Vec<&str> = reference.path.iter().map(|s| s.as_ref()).collect(); + if segments.first() == Some(&"$nodes") { + let via_exposing = segments.len() >= target.len() + 2 + && exposing.contains(segments[1]) + && target.iter().zip(&segments[2..]).all(|(t, seg)| t == seg); + if via_exposing { + if let Some(&span) = reference.spans.get(target.len() + 1) { + emit( + &node.id, + site.expression_id.clone(), + &site.source, + SpanOps::char_span(&site.source, span), + ReferenceKind::ExpressionRead, + ); + } + } + continue; + } if segments.first() == Some(&"$") { let dollar_applies = is_expression_node && !matches!(site.target, CursorTarget::TransformInput); @@ -1032,6 +1229,7 @@ impl Db { fn function_read_spans(source: &str, needle: &str, last_len: u32) -> Vec { let chars: Vec = source.chars().collect(); + let units = utf16_prefix(&chars); let mask = js_code_mask(&chars); let needle_chars: Vec = needle.chars().collect(); let mut out: Vec = Vec::new(); @@ -1051,7 +1249,7 @@ impl Db { !(next.is_alphanumeric() || next == '_' || next == '$') }; if before_ok && after_ok { - out.push(((end as u32) - last_len, end as u32)); + out.push((units[end] - last_len, units[end])); } i = end; } @@ -1059,6 +1257,17 @@ impl Db { } } +fn utf16_prefix(chars: &[char]) -> Vec { + let mut units = Vec::with_capacity(chars.len() + 1); + let mut total = 0u32; + units.push(0); + for c in chars { + total += c.len_utf16() as u32; + units.push(total); + } + units +} + pub(crate) fn js_code_mask(chars: &[char]) -> Vec { #[derive(PartialEq)] enum State { diff --git a/core/engine/src/workspace/graph/mod.rs b/core/engine/src/workspace/graph/mod.rs index 2d47e765..f9cf1378 100644 --- a/core/engine/src/workspace/graph/mod.rs +++ b/core/engine/src/workspace/graph/mod.rs @@ -4,11 +4,11 @@ mod dependencies; mod editor; mod enhance; pub(crate) mod function; -mod nl; mod queries; mod schema; mod ts_type; +pub(crate) use analysis::GraphAnalyzer; pub use analysis::{GraphAnalysis, GraphNodeAnalysis, GraphSignature}; pub use enhance::GraphTraceMap; pub use function::{FunctionResolutionRequest, FunctionTypeResolver}; diff --git a/core/engine/src/workspace/graph/nl.rs b/core/engine/src/workspace/graph/nl.rs deleted file mode 100644 index c283f269..00000000 --- a/core/engine/src/workspace/graph/nl.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::sync::Arc; - -use crate::workspace::db::Db; -use crate::workspace::graph::analysis::GraphAnalyzer; -use crate::workspace::types::{Cursor, ExpressionKind, NlExpression}; - -impl Db { - pub(crate) fn graph_nl(&self, path: &str) -> Vec { - let path_arc: Arc = Arc::from(path); - let snap = self.snapshot(); - let Some(doc) = snap.graphs.get(&path_arc).cloned() else { - return Vec::new(); - }; - let Some(content) = doc.as_graph() else { - return Vec::new(); - }; - let Some(analysis) = self.graph_analysis(&path_arc) else { - return Vec::new(); - }; - - let intellisense = self.graph_intellisense(); - intellisense - .borrow_mut() - .set_nl_labels(self.nl_label_resolver(path)); - let mut out = Vec::new(); - for node in &content.nodes { - let Some(node_analysis) = analysis.nodes.get(&node.id) else { - continue; - }; - for site in GraphAnalyzer::node_sites(node) { - let cursor = Cursor { - policy_path: path_arc.clone(), - block_id: node.id.clone(), - pos: 0, - target: site.target.clone(), - }; - let Some((source, kind, scope)) = - self.resolve_in_node(node, node_analysis, &cursor) - else { - continue; - }; - let expected = (!matches!(kind, ExpressionKind::Unary)) - .then(|| self.graph_cell_expected(&cursor)) - .flatten(); - out.push(NlExpression::project_expected( - &mut intellisense.borrow_mut(), - &path_arc, - &node.id, - site.target, - kind, - &source, - &scope, - expected.as_ref(), - )); - } - } - intellisense.borrow_mut().set_nl_labels(None); - out - } -} diff --git a/core/engine/src/workspace/graph/queries.rs b/core/engine/src/workspace/graph/queries.rs index 1a901ade..20d33b60 100644 --- a/core/engine/src/workspace/graph/queries.rs +++ b/core/engine/src/workspace/graph/queries.rs @@ -8,12 +8,9 @@ use crate::workspace::db::{Db, DictionaryUnitEntry}; use crate::workspace::graph::analysis::{ GraphAnalysis, GraphAnalyzer, GraphSignature, SignatureResolution, }; -use zen_types::decision::DecisionNodeKind; use crate::policy::queries::scope::VariableTypeScope; -use crate::workspace::types::{ - Cursor, CursorTarget, InputProperty, OutputProperty, PropertyKind, ScopeRequest, -}; +use crate::workspace::types::{InputProperty, OutputProperty, PropertyKind, ScopeRequest}; impl Db { pub(crate) fn graph_analysis(&self, path: &Arc) -> Option> { @@ -188,21 +185,6 @@ impl Db { properties } - pub(crate) fn graph_cell_expected(&self, cursor: &Cursor) -> Option { - let snap = self.snapshot(); - let doc = snap.graphs.get(&cursor.policy_path)?.clone(); - let content = doc.as_graph()?; - let node = content.nodes.iter().find(|n| n.id == cursor.block_id)?; - let DecisionNodeKind::DecisionTableNode { content } = &node.kind else { - return None; - }; - let CursorTarget::DecisionTableCell { col, .. } = &cursor.target else { - return None; - }; - let dictionaries = self.graph_dictionary_types(&doc.as_graph()?.imports); - GraphAnalyzer::output_expected(content, col, &dictionaries) - } - pub(crate) fn graph_unchecked_nodes(&self, path: &str) -> Vec> { let path_arc: Arc = Arc::from(path); let Some(analysis) = self.graph_analysis(&path_arc) else { diff --git a/core/engine/src/workspace/mod.rs b/core/engine/src/workspace/mod.rs index 7d7e6a1b..8e19a399 100644 --- a/core/engine/src/workspace/mod.rs +++ b/core/engine/src/workspace/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod db; pub(crate) mod editor; pub(crate) mod graph; pub(crate) mod search; +pub(crate) mod slot; pub(crate) mod types; use std::sync::Arc; @@ -10,22 +11,22 @@ use crate::model::DecisionContent; use crate::policy::evaluator::EvalArtifact; use crate::policy::raw::PolicyDocument; use db::Db; -use zen_expression::nl::NlResult; use zen_expression::variable::VariableType; pub use graph::{ FunctionResolutionRequest, FunctionTypeResolver, GraphAnalysis, GraphNodeAnalysis, GraphSignature, GraphTraceMap, }; +pub use slot::{CursorScope, ExpressionFacts, SlotResponse}; pub use types::{ BlockExecution, BlockRef, BlockTrace, Completion, ConditionTrace, ConditionalSchema, Cursor, CursorTarget, DecisionTableExtras, DependencyNode, Diagnostic, DiagnosticCode, DiagnosticLocation, Dictionary, DictionaryEntryInfo, DiscriminantVariant, DiscriminatedUnion, EngineEdit, Entity, EntityField, EvaluateRequest, EvaluationError, EvaluationResult, ExpressionKind, FieldOrigin, GuardedProperty, InputProperty, InputValidationError, - InspectResult, NlExpression, OutputProperty, PrepareRename, PropertyKind, ReferenceKind, - ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, SearchHit, - SearchHitKind, Severity, Span, Trace, WriteConflict, WriteTrace, + InspectResult, OutputProperty, PrepareRename, PropertyKind, ReferenceKind, ReferenceSite, + RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, SearchHit, SearchHitKind, Severity, + SlotRole, Span, Trace, WriteConflict, WriteTrace, }; use types::Global; @@ -185,12 +186,16 @@ impl Workspace { self.db.completions(cursor) } - pub fn nl(&self, policy_path: &str) -> Vec { - self.db.nl(policy_path) + pub fn cursor_scope(&self, cursor: &Cursor) -> Option { + self.db.cursor_scope(cursor) } - pub fn nl_tokenize(&self, cursor: &Cursor, text: &str) -> Option { - self.db.nl_tokenize(cursor, text) + pub fn slot(&self, cursor: &Cursor, text: &str) -> Option { + self.db.slot(cursor, text) + } + + pub fn facts(&self, policy_path: &str) -> Vec { + self.db.facts(policy_path) } pub fn prepare_rename(&self, cursor: &Cursor) -> Option { @@ -205,6 +210,23 @@ impl Workspace { self.db.references(target) } + pub fn rename_from( + &self, + target: &RenameTarget, + new_name: &str, + origin: Option<&str>, + ) -> Vec { + self.db.rename_from(target, new_name, origin) + } + + pub fn references_from( + &self, + target: &RenameTarget, + origin: Option<&str>, + ) -> Vec { + self.db.references_from(target, origin) + } + pub fn search(&self, query: &str, limit: Option) -> Vec { self.db.search(query, limit) } @@ -214,7 +236,7 @@ impl Workspace { } pub fn dependencies(&self, target: &str) -> DependencyNode { - self.db.dependencies(target) + self.db.dependencies(target, None) } pub fn dependencies_scoped(&self, target: &str, document: Option<&str>) -> DependencyNode { @@ -224,7 +246,7 @@ impl Workspace { return self.db.graph_dependencies(&doc_arc, target); } } - self.db.dependencies(target) + self.db.dependencies(target, document) } } diff --git a/core/engine/src/workspace/slot/facts.rs b/core/engine/src/workspace/slot/facts.rs new file mode 100644 index 00000000..71558708 --- /dev/null +++ b/core/engine/src/workspace/slot/facts.rs @@ -0,0 +1,232 @@ +use std::sync::Arc; + +use serde::Serialize; +use zen_expression::slot::{Literals, SlotRole, ValueOption}; +use zen_expression::variable::VariableType; +use zen_types::decision::DecisionNodeKind; + +use crate::policy::blocks::ROW_ID_KEY; +use crate::policy::raw::BlockDoc; +use crate::workspace::db::Db; +use crate::workspace::graph::GraphAnalyzer; +use crate::workspace::types::{Cursor, CursorTarget, ExpressionKind, SpanOps}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExpressionFacts { + pub block_id: Arc, + pub target: CursorTarget, + pub source: Arc, + pub kind: ExpressionKind, + pub role: SlotRole, + pub subject_type: Option, + pub expected_type: Option, + #[serde(flatten)] + pub literals: Literals, + pub subject_options: Vec, +} + +struct Site { + block_id: Arc, + target: CursorTarget, + source: Arc, +} + +impl Db { + pub fn facts(&self, policy: &str) -> Vec { + let path: Arc = Arc::from(policy); + let graph = self.is_graph(policy); + let sites = if graph { + self.graph_sites(&path) + } else { + self.policy_sites(&path) + }; + if sites.is_empty() { + return Vec::new(); + } + let labels = self.label_resolver(policy); + let intellisense = if graph { + self.graph_intellisense() + } else { + self.intellisense() + }; + intellisense.borrow_mut().set_labels(labels.clone()); + let mut out = Vec::with_capacity(sites.len()); + let mut cache = super::siblings::SiblingCache::default(); + for site in sites { + let cursor = Cursor { + policy_path: path.clone(), + block_id: site.block_id.clone(), + pos: 0, + target: site.target.clone(), + }; + let Some(scope) = self.cursor_scope_cached(&cursor, &mut cache) else { + continue; + }; + let subject_type = scope.subject_type(); + let subject_options = subject_type + .as_ref() + .filter(|_| site.source.trim().is_empty()) + .and_then(|t| ValueOption::for_type(t, labels.as_ref())) + .unwrap_or_default(); + let literals = intellisense + .borrow_mut() + .literals( + &site.source, + scope.is_unary(), + &scope.scope, + scope.literal_expected(), + ) + .map_spans(|span| SpanOps::char_span(&site.source, span)); + out.push(ExpressionFacts { + block_id: site.block_id, + target: site.target, + kind: scope.kind, + role: scope.role, + subject_type, + expected_type: scope.expected, + literals, + subject_options, + source: site.source, + }); + } + intellisense.borrow_mut().set_labels(None); + out + } + + fn policy_sites(&self, path: &Arc) -> Vec { + let Some(policy) = self.raw_policy(path) else { + return Vec::new(); + }; + let mut sites = Vec::new(); + for block in &policy.blocks { + let Some(block_id) = block.id() else { + continue; + }; + let block_id: Arc = Arc::from(block_id); + let mut push = |target: CursorTarget, source: &Arc| { + sites.push(Site { + block_id: block_id.clone(), + target, + source: source.clone(), + }); + }; + match block { + BlockDoc::DecisionTable { data: table, .. } => { + for col in &table.inputs { + if let Some(field) = col.field.as_ref().filter(|f| !f.is_empty()) { + push( + CursorTarget::DecisionTableHead { + col: col.id.clone(), + }, + field, + ); + } + } + let empty: Arc = Arc::from(""); + for rule in &table.rules { + let Some(row) = rule.get(ROW_ID_KEY) else { + continue; + }; + let ids = table + .inputs + .iter() + .map(|c| &c.id) + .chain(table.outputs.iter().map(|c| &c.id)); + for col in ids { + push( + CursorTarget::DecisionTableCell { + row: row.clone(), + col: col.clone(), + }, + rule.get(col).unwrap_or(&empty), + ); + } + } + } + BlockDoc::Expression { id, data } => { + if !data.value.is_empty() { + push(CursorTarget::Expression { id: id.clone() }, &data.value); + } + } + BlockDoc::Assertion { data, .. } => { + for condition in &data.conditions { + if !condition.expression.is_empty() { + push( + CursorTarget::Expression { + id: condition.id.clone(), + }, + &condition.expression, + ); + } + } + } + BlockDoc::Match { data, .. } => { + if !data.key.is_empty() { + push(CursorTarget::MatchTarget, &data.key); + } + for arm in &data.arms { + if !arm.condition.is_empty() { + push( + CursorTarget::Expression { id: arm.id.clone() }, + &arm.condition, + ); + } + if !arm.value.is_empty() { + push(CursorTarget::MatchValue { id: arm.id.clone() }, &arm.value); + } + } + } + BlockDoc::DataModel { .. } | BlockDoc::Dictionary { .. } | BlockDoc::Ignored(_) => { + } + } + } + sites + } + + fn graph_sites(&self, path: &Arc) -> Vec { + let snap = self.snapshot(); + let Some(content) = snap.graphs.get(path).and_then(|doc| doc.as_graph()) else { + return Vec::new(); + }; + let empty: Arc = Arc::from(""); + content + .nodes + .iter() + .flat_map(|node| { + let mut sites: Vec = GraphAnalyzer::node_sites(node) + .into_iter() + .map(|site| Site { + block_id: node.id.clone(), + target: site.target, + source: site.source, + }) + .collect(); + if let DecisionNodeKind::DecisionTableNode { content: table } = &node.kind { + for (index, rule) in table.rules.iter().enumerate() { + let row = GraphAnalyzer::row_key(rule, index); + let columns = table + .inputs + .iter() + .map(|c| &c.id) + .chain(table.outputs.iter().map(|c| &c.id)); + for col in columns { + if rule.get(col).is_some_and(|cell| !cell.is_empty()) { + continue; + } + sites.push(Site { + block_id: node.id.clone(), + target: CursorTarget::DecisionTableCell { + row: row.clone(), + col: col.clone(), + }, + source: empty.clone(), + }); + } + } + } + sites + }) + .collect() + } +} diff --git a/core/engine/src/workspace/slot/mod.rs b/core/engine/src/workspace/slot/mod.rs new file mode 100644 index 00000000..ea26b0d8 --- /dev/null +++ b/core/engine/src/workspace/slot/mod.rs @@ -0,0 +1,302 @@ +mod facts; +mod scope; +mod siblings; + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use ahash::{HashMap, HashMapExt}; +use serde::Serialize; +use zen_expression::intellisense::IntelliSense; +use zen_expression::slot::{LabelResolver, Literals, Slot, SlotResult}; +use zen_expression::variable::VariableType; + +use crate::policy::ir::DictionaryIr; +use crate::workspace::db::{Db, Unit}; +use crate::workspace::graph::GraphAnalysis; +use crate::workspace::types::{Cursor, CursorTarget, ExpressionKind, SpanOps}; + +pub use facts::ExpressionFacts; +pub use zen_expression::slot::SlotRole; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SlotResponse { + pub kind: ExpressionKind, + pub role: SlotRole, + pub subject_type: Option, + pub expected_type: Option, + pub slot: Slot, + #[serde(flatten)] + pub literals: Literals, +} + +impl SlotResponse { + pub fn compute(is: &mut IntelliSense, scope: &CursorScope, text: &str, pos: u32) -> Self { + let pos = SpanOps::byte_offset(text, pos) as u32; + let SlotResult { mut slot, literals } = is.slot( + text, + pos, + scope.is_unary(), + scope.role, + &scope.scope, + scope.expected.as_ref(), + ); + let literals = if scope.inferred { + is.literals(text, scope.is_unary(), &scope.scope, None) + } else { + literals + }; + slot.replace_span = SpanOps::char_span(text, slot.replace_span); + Self { + kind: scope.kind, + role: scope.role, + subject_type: scope.subject_type(), + expected_type: scope.expected.as_ref().map(VariableType::shallow_clone), + slot, + literals: literals.map_spans(|span| SpanOps::char_span(text, span)), + } + } +} + +type LabelMap = HashMap, HashMap, Arc>>; + +enum LabelKey { + Unit(Arc), + Graph(Arc), +} + +impl LabelKey { + fn same(&self, other: &LabelKey) -> bool { + match (self, other) { + (LabelKey::Unit(a), LabelKey::Unit(b)) => Arc::ptr_eq(a, b), + (LabelKey::Graph(a), LabelKey::Graph(b)) => Arc::ptr_eq(a, b), + _ => false, + } + } +} + +type LabelEntry = (LabelKey, Option>); + +#[derive(Default)] +pub(crate) struct LabelCache { + entries: RefCell, LabelEntry>>, +} + +impl LabelCache { + fn label_map( + dictionaries: impl Iterator, Arc)>, + ) -> Option> { + let mut labels = LabelMap::new(); + for (name, dict) in dictionaries { + let entries: HashMap, Arc> = dict + .entries + .iter() + .filter(|e| !e.label.is_empty()) + .map(|e| (e.value.clone(), e.label.clone())) + .collect(); + if !entries.is_empty() { + labels.insert(name, entries); + } + } + (!labels.is_empty()).then(|| Arc::new(labels)) + } +} + +impl Db { + pub fn slot(&self, cursor: &Cursor, text: &str) -> Option { + let scope = self.cursor_scope(cursor)?; + let labels = self.label_resolver(&cursor.policy_path); + let intellisense = self.cursor_intellisense(cursor); + let mut is = intellisense.borrow_mut(); + is.set_labels(labels); + let response = SlotResponse::compute(&mut is, &scope, text, cursor.pos); + is.set_labels(None); + Some(response) + } + + pub(crate) fn label_resolver(&self, policy: &str) -> Option { + let key = if self.is_graph(policy) { + let path: Arc = Arc::from(policy); + LabelKey::Graph(self.graph_analysis(&path)?) + } else { + LabelKey::Unit(self.unit(policy)) + }; + let cached = self + .labels + .entries + .borrow() + .get(policy) + .filter(|(cached, _)| cached.same(&key)) + .map(|(_, map)| map.clone()); + let map = match cached { + Some(map) => map, + None => { + let map = match &key { + LabelKey::Graph(_) => LabelCache::label_map( + self.graph_dictionary_blocks(&self.graph_imports(policy)) + .into_iter() + .map(|entry| (entry.ir.name.clone(), entry.ir)), + ), + LabelKey::Unit(unit) => LabelCache::label_map( + unit.dictionaries + .iter() + .map(|(name, dict)| (name.clone(), dict.clone())), + ), + }; + self.labels + .entries + .borrow_mut() + .insert(Arc::from(policy), (key, map.clone())); + map + } + }?; + Some(Rc::new(move |name: &str, value: &str| { + map.get(name)?.get(value).map(|l| l.to_string()) + })) + } +} + +#[derive(Debug, Clone)] +pub struct CursorScope { + pub kind: ExpressionKind, + pub role: SlotRole, + pub scope: VariableType, + pub expected: Option, + /// `expected` was guessed from sibling cells: good for suggestions, not a declared type. + pub inferred: bool, +} + +impl CursorScope { + fn unary(scope: VariableType) -> Self { + Self { + kind: ExpressionKind::Unary, + role: SlotRole::Unary, + scope, + expected: None, + inferred: false, + } + } + + fn condition(scope: VariableType) -> Self { + Self { + kind: ExpressionKind::Standard, + role: SlotRole::Condition, + scope, + expected: Some(VariableType::Bool), + inferred: false, + } + } + + fn value(scope: VariableType, expected: Option) -> Self { + Self { + kind: ExpressionKind::Standard, + role: SlotRole::Value, + scope, + expected, + inferred: false, + } + } + + fn path(scope: VariableType) -> Self { + Self { + kind: ExpressionKind::Standard, + role: SlotRole::Path, + scope, + expected: None, + inferred: false, + } + } + + fn with_inferred(mut self, inferred: bool) -> Self { + self.inferred = inferred && self.expected.is_some(); + self + } + + /// Type that literals are classified against; a sibling guess never turns plain strings into enum values. + pub fn literal_expected(&self) -> Option<&VariableType> { + if self.inferred { + None + } else { + self.expected.as_ref() + } + } + + pub fn is_unary(&self) -> bool { + matches!(self.kind, ExpressionKind::Unary) + } + + pub fn subject_type(&self) -> Option { + match self.kind { + ExpressionKind::Unary => Some(self.scope.get("$")), + ExpressionKind::Standard => self.expected.as_ref().map(VariableType::shallow_clone), + } + } + + pub(super) fn known_type(resolved: VariableType) -> Option { + let (base, _) = resolved.unwrap_nullable(); + match base { + VariableType::Any | VariableType::Null => None, + _ => Some(resolved), + } + } + + pub(super) fn date_hint(kind: VariableType) -> VariableType { + match kind { + VariableType::String => VariableType::Date, + VariableType::Array(inner) => Self::date_hint(inner.shallow_clone()).array(), + VariableType::Nullable(inner) => { + VariableType::Nullable(Rc::new(Self::date_hint(inner.shallow_clone()))) + } + other => other, + } + } + + pub(super) fn literal_union(merged: VariableType) -> Option { + let (base, _) = merged.unwrap_nullable(); + match base { + VariableType::Enum(..) + | VariableType::Const(_) + | VariableType::Bool + | VariableType::Number + | VariableType::Date => Some(base.shallow_clone()), + _ => None, + } + } + + pub(super) fn collected_type(value: VariableType, collect: bool) -> VariableType { + if collect { + value + .iterator() + .map(|t| t.as_ref().shallow_clone()) + .unwrap_or(value) + } else { + value + } + } +} + +impl Db { + pub fn cursor_scope(&self, cursor: &Cursor) -> Option { + self.cursor_scope_cached(cursor, &mut siblings::SiblingCache::default()) + } + + fn cursor_scope_cached( + &self, + cursor: &Cursor, + cache: &mut siblings::SiblingCache, + ) -> Option { + if matches!( + cursor.target, + CursorTarget::DataModelName | CursorTarget::DataModelProperty { .. } + ) { + return None; + } + if self.is_graph(&cursor.policy_path) { + self.graph_cursor_scope(cursor, cache) + } else { + self.policy_cursor_scope(cursor, cache) + } + } +} diff --git a/core/engine/src/workspace/slot/scope.rs b/core/engine/src/workspace/slot/scope.rs new file mode 100644 index 00000000..a3e57251 --- /dev/null +++ b/core/engine/src/workspace/slot/scope.rs @@ -0,0 +1,390 @@ +use std::rc::Rc; +use std::sync::Arc; + +use zen_expression::variable::VariableType; +use zen_types::decision::{ + DecisionNodeKind, DecisionTableContent, DecisionTableHitPolicy, OutputNodeContent, + TransformAttributes, TransformExecutionMode, +}; + +use super::siblings::SiblingCache; +use super::CursorScope; +use crate::model::GraphContent; +use crate::policy::blocks::{ + BlockKind, DecisionTableIr, IntelliSenseSource, SharedIntelliSense, ROW_ID_KEY, +}; +use crate::policy::ir::PropertyTypeIr; +use crate::policy::queries::scope::VariableTypeScope; +use crate::workspace::db::{Db, Unit}; +use crate::workspace::graph::{GraphAnalyzer, GraphNodeAnalysis, SchemaType}; +use crate::workspace::types::{BlockRef, Cursor, CursorTarget, ExpressionKind}; + +impl Db { + pub(super) fn policy_cursor_scope( + &self, + cursor: &Cursor, + cache: &mut SiblingCache, + ) -> Option { + let block_ref = BlockRef { + policy_path: cursor.policy_path.clone(), + block_id: cursor.block_id.clone(), + }; + let block = self.block_ir(&block_ref)?; + let unit = self.unit(&cursor.policy_path); + let enriched = self.enriched_of_unit(&unit); + let scope = enriched.scope_excluding(&block_ref); + match (&block.kind, &cursor.target) { + (BlockKind::DecisionTable(table), _) => { + self.policy_table_scope(&unit, table, cursor, scope, &enriched.scope, cache) + } + (BlockKind::Expression(_), CursorTarget::ExpressionKey { .. }) + | (BlockKind::Assertion(_), CursorTarget::AssertionOutput) + | (BlockKind::Match(_), CursorTarget::MatchTarget) => Some(CursorScope::path(scope)), + (BlockKind::Expression(expression), CursorTarget::Expression { .. }) => Some( + CursorScope::value(scope, self.declared_type(&unit, &expression.key)), + ), + (BlockKind::Match(block), CursorTarget::MatchValue { .. }) => Some(CursorScope::value( + scope, + self.declared_type(&unit, &block.key), + )), + (BlockKind::Assertion(_) | BlockKind::Match(_), CursorTarget::Expression { .. }) => { + Some(CursorScope::condition(scope)) + } + _ => None, + } + } + + fn policy_table_scope( + &self, + unit: &Unit, + table: &DecisionTableIr, + cursor: &Cursor, + scope: VariableType, + written: &VariableType, + cache: &mut SiblingCache, + ) -> Option { + match &cursor.target { + CursorTarget::DecisionTableHead { col } => { + if table.inputs.iter().any(|c| c.id == *col) { + return Some(CursorScope::value(scope, None)); + } + table + .outputs + .iter() + .any(|c| c.id == *col) + .then(|| CursorScope::path(scope)) + } + CursorTarget::DecisionTableCell { col, .. } => { + if let Some(column) = table.inputs.iter().find(|c| c.id == *col) { + return Some(match column.field.as_ref().filter(|f| !f.is_empty()) { + Some(field) => { + let field_type = Self::return_type(&self.intellisense(), field, &scope); + CursorScope::unary(scope.with_dollar(&field_type)) + } + None => CursorScope::condition(scope), + }); + } + let column = table.outputs.iter().find(|c| c.id == *col)?; + let expected = column + .declared + .as_ref() + .and_then(|declared| declared.resolve(&unit.dictionary_types())) + .or_else(|| { + self.declared_type(unit, column.field.as_ref()) + .map(|t| CursorScope::collected_type(t, column.collect)) + }); + // The table is the field's only writer, so its written type is its own cells' union. + let inferred = expected.is_none(); + let expected = expected + .or_else(|| { + self.written_type(unit, written, column.field.as_ref()) + .map(|t| CursorScope::collected_type(t, column.collect)) + }) + .or_else(|| { + let is = self.intellisense(); + cache.infer(cursor, || { + table.rules.iter().filter_map(|rule| { + let id = rule.get(ROW_ID_KEY)?.clone(); + let t = rule + .get(col) + .filter(|cell| !cell.is_empty()) + .map(|cell| Self::return_type(&is, cell, &scope)); + Some((id, t)) + }) + }) + }); + Some(CursorScope::value(scope, expected).with_inferred(inferred)) + } + _ => None, + } + } + + fn declared_type(&self, unit: &Unit, path: &str) -> Option { + if path.is_empty() { + return None; + } + let kind = CursorScope::known_type(self.enriched_of_unit(unit).declared_at(path))?; + let segments: Vec> = path.split('.').map(Rc::from).collect(); + let (field, parent) = segments.split_last()?; + let property = if parent.is_empty() { + unit.entity_graph.global_property(field) + } else { + unit.entity_graph + .resolve_path_to_element(parent) + .and_then(|entity| unit.entities.get(&entity)) + .and_then(|model| { + model + .properties + .iter() + .find(|prop| prop.name.as_ref() == field.as_ref()) + }) + }; + Some( + if property.is_some_and(|prop| matches!(prop.kind, PropertyTypeIr::Date)) { + CursorScope::date_hint(kind) + } else { + kind + }, + ) + } + + fn written_type(&self, unit: &Unit, scope: &VariableType, path: &str) -> Option { + if path.is_empty() { + return None; + } + self.declared_type(unit, path) + .or_else(|| CursorScope::literal_union(scope.resolve_at(path))) + } + + pub(super) fn graph_cursor_scope( + &self, + cursor: &Cursor, + cache: &mut SiblingCache, + ) -> Option { + let snap = self.snapshot(); + let doc = snap.graphs.get(&cursor.policy_path)?.clone(); + let content = doc.as_graph()?; + let analysis = self.graph_analysis(&cursor.policy_path)?; + let node = content.nodes.iter().find(|n| n.id == cursor.block_id)?; + let node_analysis = analysis.nodes.get(&cursor.block_id)?; + let input_scope = + || GraphAnalyzer::scope_with_nodes(&node_analysis.input, &node_analysis.nodes_scope); + + match (&node.kind, &cursor.target) { + ( + DecisionNodeKind::ExpressionNode { .. } + | DecisionNodeKind::DecisionTableNode { .. } + | DecisionNodeKind::DecisionNode { .. }, + CursorTarget::TransformInput, + ) => Some(CursorScope::value(input_scope(), None)), + (DecisionNodeKind::ExpressionNode { .. }, CursorTarget::ExpressionKey { .. }) => { + Some(CursorScope::path(input_scope())) + } + ( + DecisionNodeKind::ExpressionNode { content: rows }, + CursorTarget::Expression { id }, + ) => { + let scope = GraphAnalyzer::scope_with( + &node_analysis.handler_input, + &[ + ("$", node_analysis.dollar_before(&rows.expressions, id)), + ("$nodes", node_analysis.nodes_scope.shallow_clone()), + ], + ); + let expected = rows + .expressions + .iter() + .find(|row| row.id == *id) + .filter(|row| !row.key.is_empty()) + .and_then(|row| { + self.output_schema_type( + content, + &cursor.block_id, + &row.key, + rows.transform_attributes.output_path.as_deref(), + ) + }); + Some(CursorScope::value(scope, expected)) + } + (DecisionNodeKind::SwitchNode { .. }, CursorTarget::Expression { .. }) => { + Some(CursorScope::condition(input_scope())) + } + (DecisionNodeKind::DecisionTableNode { content: table }, _) => { + self.graph_table_scope(content, table, node_analysis, cursor, cache) + } + _ => None, + } + } + + fn graph_table_scope( + &self, + content: &GraphContent, + table: &DecisionTableContent, + node_analysis: &GraphNodeAnalysis, + cursor: &Cursor, + cache: &mut SiblingCache, + ) -> Option { + let scope = GraphAnalyzer::scope_with_nodes( + &node_analysis.handler_input, + &node_analysis.nodes_scope, + ); + match &cursor.target { + CursorTarget::DecisionTableHead { col } => { + if table.inputs.iter().any(|c| c.id == *col) { + return Some(CursorScope::value(scope, None)); + } + table + .outputs + .iter() + .any(|c| c.id == *col) + .then(|| CursorScope::path(scope)) + } + CursorTarget::DecisionTableCell { col, .. } => { + let is = self.graph_intellisense(); + if let Some(column) = table.inputs.iter().find(|c| c.id == *col) { + return Some(match column.field.as_ref().filter(|f| !f.is_empty()) { + Some(field) => { + let field_type = Self::return_type(&is, field, &scope); + CursorScope::unary(scope.with_dollar(&field_type)) + } + None => CursorScope::condition(scope), + }); + } + let column = table.outputs.iter().find(|c| c.id == *col)?; + let dictionaries = self.graph_dictionary_types(&content.imports); + let expected = + GraphAnalyzer::output_expected(table, col, &dictionaries).or_else(|| { + (!column.field.is_empty()) + .then(|| { + self.output_schema_type( + content, + &cursor.block_id, + column.field.strip_suffix("[]").unwrap_or(&column.field), + table.transform_attributes.output_path.as_deref(), + ) + .map(|t| { + CursorScope::collected_type(t, column.field.ends_with("[]")) + }) + }) + .flatten() + }); + let inferred = expected.is_none(); + let expected = expected.or_else(|| { + cache.infer(cursor, || { + table.rules.iter().enumerate().map(|(index, rule)| { + let id = GraphAnalyzer::row_key(rule, index); + let t = rule + .get(col) + .filter(|cell| !cell.is_empty()) + .map(|cell| Self::return_type(&is, cell, &scope)); + (id, t) + }) + }) + }); + Some(CursorScope::value(scope, expected).with_inferred(inferred)) + } + _ => None, + } + } + + fn output_schema_type( + &self, + content: &GraphContent, + node_id: &str, + key: &str, + output_path: Option<&str>, + ) -> Option { + let dictionaries = self.graph_dictionary_types(&content.imports); + let key = match output_path.filter(|p| !p.is_empty()) { + Some(path) => format!("{path}.{key}"), + None => key.to_string(), + }; + let mut resolved: Option = None; + for output in Self::reachable_outputs(content, node_id) { + let schema = output.schema.as_ref()?; + let found = CursorScope::known_type( + SchemaType::variable_type_with(schema, &dictionaries).resolve_at(&key), + )?; + resolved = match resolved { + None => Some(found), + Some(existing) => { + let (left, _) = existing.unwrap_nullable(); + let (right, _) = found.unwrap_nullable(); + if !(left.satisfies(right) && right.satisfies(left)) { + return None; + } + let merged = existing.merge(&found); + match existing.is_nullable() && found.is_nullable() { + true => Some(merged), + false => Some(merged.unwrap_nullable().0.shallow_clone()), + } + } + }; + } + resolved + } + + fn reachable_outputs<'c>( + content: &'c GraphContent, + node_id: &'c str, + ) -> Vec<&'c OutputNodeContent> { + let mut visited: Vec<&str> = vec![node_id]; + let mut queue: Vec<&str> = vec![node_id]; + let mut outputs = Vec::new(); + while let Some(current) = queue.pop() { + for edge in content + .edges + .iter() + .filter(|e| e.source_id.as_ref() == current) + { + let Some(target) = content.nodes.iter().find(|n| n.id == edge.target_id) else { + continue; + }; + let passes = match &target.kind { + DecisionNodeKind::OutputNode { content: output } => { + outputs.push(output); + false + } + DecisionNodeKind::SwitchNode { .. } => true, + DecisionNodeKind::ExpressionNode { content: node } => { + Self::passes_through(&node.transform_attributes, false) + } + DecisionNodeKind::DecisionNode { content: node } => { + Self::passes_through(&node.transform_attributes, false) + } + DecisionNodeKind::DecisionTableNode { content: node } => Self::passes_through( + &node.transform_attributes, + node.hit_policy == DecisionTableHitPolicy::Collect, + ), + _ => false, + }; + if passes && !visited.contains(&target.id.as_ref()) { + visited.push(target.id.as_ref()); + queue.push(target.id.as_ref()); + } + } + } + outputs + } + + fn passes_through(attributes: &TransformAttributes, collects: bool) -> bool { + attributes.pass_through + && (attributes.output_path.is_some() + || (!collects && attributes.execution_mode == TransformExecutionMode::Single)) + } + + fn return_type( + is: &SharedIntelliSense, + source: &Arc, + scope: &VariableType, + ) -> VariableType { + IntelliSenseSource::analyze( + &mut is.borrow_mut(), + source, + ExpressionKind::Standard, + scope, + ) + .return_type + .shallow_clone() + } +} diff --git a/core/engine/src/workspace/slot/siblings.rs b/core/engine/src/workspace/slot/siblings.rs new file mode 100644 index 00000000..6cd72a50 --- /dev/null +++ b/core/engine/src/workspace/slot/siblings.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use ahash::HashMap; +use zen_expression::variable::VariableType; + +use super::CursorScope; +use crate::workspace::types::{Cursor, CursorTarget}; + +type ColumnKey = (Arc, Arc, Arc); + +#[derive(Default)] +pub(super) struct SiblingCache { + columns: HashMap>, +} + +struct ColumnTypes { + distinct: Vec<(VariableType, usize)>, + rows: HashMap, usize>, +} + +impl ColumnTypes { + const MAX_DISTINCT: usize = 64; + + fn collect(cells: impl IntoIterator, Option)>) -> Option { + let mut distinct: Vec<(VariableType, usize)> = Vec::new(); + let mut index_of: HashMap = HashMap::default(); + let mut rows = HashMap::default(); + for (id, value) in cells { + let Some(value) = value else { + continue; + }; + let index = *index_of.entry(value.to_string()).or_insert_with(|| { + distinct.push((value.shallow_clone(), 0)); + distinct.len() - 1 + }); + if distinct.len() > Self::MAX_DISTINCT { + return None; + } + distinct[index].1 += 1; + rows.insert(id, index); + } + Some(Self { distinct, rows }) + } + + fn union_without(&self, skip: Option) -> Option { + self.distinct + .iter() + .enumerate() + .filter(|(index, _)| Some(*index) != skip) + .map(|(_, (value, _))| value) + .fold(None, |acc: Option, value| { + Some(match acc { + Some(acc) => acc.merge(value), + None => value.shallow_clone(), + }) + }) + } + + fn excluding(&self, row: &Arc) -> Option { + let own = self + .rows + .get(row) + .copied() + .filter(|index| self.distinct[*index].1 == 1); + self.union_without(own) + } +} + +impl SiblingCache { + pub(super) fn infer( + &mut self, + cursor: &Cursor, + analyze: impl FnOnce() -> I, + ) -> Option + where + I: IntoIterator, Option)>, + { + let (row, col) = match &cursor.target { + CursorTarget::DecisionTableCell { row, col } => (row, col.clone()), + CursorTarget::MatchValue { id } => (id, Arc::from("match")), + _ => return None, + }; + let key = (cursor.policy_path.clone(), cursor.block_id.clone(), col); + self.columns + .entry(key) + .or_insert_with(|| ColumnTypes::collect(analyze())) + .as_ref()? + .excluding(row) + .and_then(CursorScope::literal_union) + } +} diff --git a/core/engine/src/workspace/types/cursor.rs b/core/engine/src/workspace/types/cursor.rs index 3b180683..e955716a 100644 --- a/core/engine/src/workspace/types/cursor.rs +++ b/core/engine/src/workspace/types/cursor.rs @@ -5,6 +5,8 @@ use zen_expression::variable::VariableType; use super::Span; +pub use crate::workspace::slot::SlotRole; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Cursor { @@ -17,15 +19,29 @@ pub struct Cursor { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum CursorTarget { - Expression { id: Arc }, + Expression { + id: Arc, + }, AssertionOutput, - ExpressionKey, + ExpressionKey { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option>, + }, MatchTarget, - MatchValue { id: Arc }, - DecisionTableHead { col: Arc }, - DecisionTableCell { row: Arc, col: Arc }, + MatchValue { + id: Arc, + }, + DecisionTableHead { + col: Arc, + }, + DecisionTableCell { + row: Arc, + col: Arc, + }, DataModelName, - DataModelProperty { id: Arc }, + DataModelProperty { + id: Arc, + }, TransformInput, } @@ -42,6 +58,10 @@ pub struct InspectResult { pub span: Span, pub kind: VariableType, pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/core/engine/src/workspace/types/diagnostic.rs b/core/engine/src/workspace/types/diagnostic.rs index 55989995..d79e2270 100644 --- a/core/engine/src/workspace/types/diagnostic.rs +++ b/core/engine/src/workspace/types/diagnostic.rs @@ -10,20 +10,34 @@ pub(crate) struct SpanOps; impl SpanOps { pub(crate) fn char_len(s: &str) -> u32 { - s.chars().count() as u32 + s.encode_utf16().count() as u32 } pub(crate) fn char_span(source: &str, span: Span) -> Span { - if source.is_ascii() { - return span; + ( + Self::char_offset(source, span.0 as usize), + Self::char_offset(source, span.1 as usize), + ) + } + + pub(crate) fn byte_offset(source: &str, pos: u32) -> usize { + let mut units = 0u32; + for (byte, ch) in source.char_indices() { + let len = ch.len_utf16() as u32; + if pos < units + len { + return byte; + } + units += len; } - let to_char = |byte: u32| { - source - .char_indices() - .take_while(|(at, _)| (*at as u32) < byte) - .count() as u32 - }; - (to_char(span.0), to_char(span.1)) + source.len() + } + + fn char_offset(source: &str, byte: usize) -> u32 { + let mut end = byte.min(source.len()); + while !source.is_char_boundary(end) { + end -= 1; + } + Self::char_len(&source[..end]) } pub(crate) fn replace_at_char_spans(source: &str, spans: &[Span], new_text: &str) -> String { @@ -41,14 +55,8 @@ impl SpanOps { let mut out = source.to_string(); for (char_start, char_end) in sorted.into_iter().rev() { - let byte_start = out - .char_indices() - .nth(char_start as usize) - .map_or(out.len(), |(b, _)| b); - let byte_end = out - .char_indices() - .nth(char_end as usize) - .map_or(out.len(), |(b, _)| b); + let byte_start = Self::byte_offset(&out, char_start); + let byte_end = Self::byte_offset(&out, char_end); out.replace_range(byte_start..byte_end, new_text); } out @@ -62,8 +70,14 @@ pub struct Diagnostic { pub message: String, pub severity: Severity, pub location: DiagnosticLocation, + #[serde(skip_serializing_if = "Option::is_none")] + pub expr_code: Option<&'static str>, + #[serde(skip_serializing_if = "DiagnosticArgs::is_empty")] + pub args: DiagnosticArgs, } +pub use zen_expression::intellisense::diagnostic::DiagnosticArgs; + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DiagnosticLocation { @@ -222,6 +236,8 @@ impl Diagnostic { message: message.into(), severity: Severity::Error, location, + expr_code: None, + args: DiagnosticArgs::new(), } } @@ -235,6 +251,8 @@ impl Diagnostic { message: message.into(), severity: Severity::Warning, location, + expr_code: None, + args: DiagnosticArgs::new(), } } @@ -248,9 +266,17 @@ impl Diagnostic { message: message.into(), severity: Severity::Hint, location, + expr_code: None, + args: DiagnosticArgs::new(), } } + pub fn with_expr_code(mut self, code: &'static str, args: DiagnosticArgs) -> Self { + self.expr_code = Some(code); + self.args = args; + self + } + pub(crate) fn from_expression( diag: &zen_expression::intellisense::diagnostic::Diagnostic, location: DiagnosticLocation, @@ -268,6 +294,8 @@ impl Diagnostic { span: location.span.or(Some(diag.span)), ..location }, + expr_code: diag.code, + args: diag.args.clone(), } } diff --git a/core/engine/src/workspace/types/mod.rs b/core/engine/src/workspace/types/mod.rs index 55b904d5..2a946dac 100644 --- a/core/engine/src/workspace/types/mod.rs +++ b/core/engine/src/workspace/types/mod.rs @@ -2,20 +2,20 @@ mod cursor; mod diagnostic; mod edit; mod error; -mod nl; mod request; mod result; mod search; pub use cursor::{ Cursor, CursorTarget, ExpressionKind, InspectResult, PrepareRename, ReferenceKind, - ReferenceSite, RenameTarget, + ReferenceSite, RenameTarget, SlotRole, }; pub(crate) use diagnostic::SpanOps; -pub use diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLocation, Severity, Span}; +pub use diagnostic::{ + Diagnostic, DiagnosticArgs, DiagnosticCode, DiagnosticLocation, Severity, Span, +}; pub use edit::EngineEdit; pub use error::{EvaluationError, InputValidationError}; -pub use nl::NlExpression; pub use request::{EvaluateRequest, ScopeRequest}; pub use result::{ BlockExecution, BlockRef, BlockTrace, Completion, ConditionTrace, ConditionalSchema, diff --git a/core/engine/src/workspace/types/nl.rs b/core/engine/src/workspace/types/nl.rs deleted file mode 100644 index 174ccb19..00000000 --- a/core/engine/src/workspace/types/nl.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::sync::Arc; - -use zen_expression::intellisense::IntelliSense; -use zen_expression::nl::NlResult; -use zen_expression::variable::VariableType; - -use crate::workspace::types::{CursorTarget, ExpressionKind}; - -#[derive(Debug, Clone)] -pub struct NlExpression { - pub policy_path: Arc, - pub block_id: Arc, - pub target: CursorTarget, - pub kind: ExpressionKind, - pub source: String, - pub result: NlResult, -} - -impl NlExpression { - pub(crate) fn project( - is: &mut IntelliSense, - policy_path: &Arc, - block_id: &Arc, - target: CursorTarget, - kind: ExpressionKind, - source: &str, - scope: &VariableType, - ) -> Self { - Self::project_expected(is, policy_path, block_id, target, kind, source, scope, None) - } - - #[allow(clippy::too_many_arguments)] - pub(crate) fn project_expected( - is: &mut IntelliSense, - policy_path: &Arc, - block_id: &Arc, - target: CursorTarget, - kind: ExpressionKind, - source: &str, - scope: &VariableType, - expected: Option<&VariableType>, - ) -> Self { - let unary = matches!(kind, ExpressionKind::Unary); - let mut result = is.nl_tokenize_scoped(block_id, source, unary, scope, expected); - if unary { - let subject = scope.get("$"); - result.subject_options = is.nl_subject_options(&subject); - result.subject_type = Some(subject); - } else if let Some(expected) = expected { - result.subject_options = is.nl_subject_options(expected); - result.subject_type = Some(expected.shallow_clone()); - } - Self { - policy_path: policy_path.clone(), - block_id: block_id.clone(), - target, - kind, - source: source.to_string(), - result, - } - } -} diff --git a/core/engine/tests/data/policy/completions.toml b/core/engine/tests/data/policy/completions.toml index 728b458a..026cc272 100644 --- a/core/engine/tests/data/policy/completions.toml +++ b/core/engine/tests/data/policy/completions.toml @@ -9,7 +9,8 @@ # so top-level cases assert all entities are offered; the meaningful scoping # checks live at the property level (after a dot). -policies = ["analysis.json"] +policies = ["analysis.json", "completion_scope.json"] +graphs = ["completion_scope_graph.json"] [[test]] name = "expression top-level: all entities offered" @@ -59,3 +60,31 @@ expression_id = "o1" row = "r1" pos = 0 includes = ["customer"] + +[[test]] +name = "field written by an independent later block is offered, own write is not" +policy = "completion_scope.json" +block_id = "b1" +expression_id = "b1" +pos = 2 +includes = ["z"] +excludes = ["x"] + +[[test]] +name = "half-typed write target is not offered as its own completion" +policy = "completion_scope.json" +block_id = "b3" +expression_id = "b3" +pos = 4 +key = true +includes = ["part"] +excludes = ["pa"] + +[[test]] +name = "graph expression row sees only earlier rows through $" +policy = "completion_scope_graph.json" +block_id = "calc" +expression_id = "e2" +pos = 2 +includes = ["total"] +excludes = ["vip", "extra"] diff --git a/core/engine/tests/data/policy/dependencies.toml b/core/engine/tests/data/policy/dependencies.toml index e12645c2..b9eadbce 100644 --- a/core/engine/tests/data/policy/dependencies.toml +++ b/core/engine/tests/data/policy/dependencies.toml @@ -108,3 +108,18 @@ content = ''' ] } ''' + +[[test]] +name = "dependencies follow the open policy when siblings write the same field (A)" +policies = ["merge_policy_a.json", "merge_policy_b.json", "merge_policy_c.json"] +policy = "merge_policy_a.json" +target = "customer.greeting" +inputs = ["customer.name"] + +[[test]] +name = "dependencies follow the open policy when siblings write the same field (C)" +policies = ["merge_policy_a.json", "merge_policy_b.json", "merge_policy_c.json"] +policy = "merge_policy_c.json" +target = "customer.greeting" +inputs = ["customer.country"] + diff --git a/core/engine/tests/data/policy/fixtures/completion_scope.json b/core/engine/tests/data/policy/fixtures/completion_scope.json new file mode 100644 index 00000000..e58b3df5 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/completion_scope.json @@ -0,0 +1,63 @@ +{ + "blocks": [ + { + "id": "dm", + "type": "dataModel", + "props": { + "data": { + "name": "c", + "properties": [ + { + "id": "p1", + "name": "a", + "type": "number", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "b1", + "type": "expression", + "props": { + "data": { + "key": "r.x", + "value": "r." + } + } + }, + { + "id": "b2", + "type": "expression", + "props": { + "data": { + "key": "r.z", + "value": "c.a * 3" + } + } + }, + { + "id": "b3", + "type": "expression", + "props": { + "data": { + "key": "r.pa", + "value": "1" + } + } + }, + { + "id": "b4", + "type": "expression", + "props": { + "data": { + "key": "r.part", + "value": "2" + } + } + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/completion_scope_graph.json b/core/engine/tests/data/policy/fixtures/completion_scope_graph.json new file mode 100644 index 00000000..56581367 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/completion_scope_graph.json @@ -0,0 +1,72 @@ +{ + "nodes": [ + { + "id": "in", + "type": "inputNode", + "name": "in", + "position": { + "x": 0, + "y": 0 + }, + "content": { + "schema": "{\"type\": \"object\", \"properties\": {\"age\": {\"type\": \"number\"}}, \"required\": [\"age\"]}" + } + }, + { + "id": "calc", + "type": "expressionNode", + "name": "calc", + "position": { + "x": 0, + "y": 100 + }, + "content": { + "expressions": [ + { + "id": "e1", + "key": "total", + "value": "age * 2" + }, + { + "id": "e2", + "key": "vip", + "value": "$." + }, + { + "id": "e3", + "key": "extra", + "value": "1" + } + ], + "passThrough": true, + "inputField": null, + "outputPath": null, + "executionMode": "single" + } + }, + { + "id": "out", + "type": "outputNode", + "name": "out", + "position": { + "x": 0, + "y": 200 + }, + "content": {} + } + ], + "edges": [ + { + "id": "x1", + "sourceId": "in", + "targetId": "calc", + "type": "edge" + }, + { + "id": "x2", + "sourceId": "calc", + "targetId": "out", + "type": "edge" + } + ] +} diff --git a/core/engine/tests/data/policy/fixtures/slots.json b/core/engine/tests/data/policy/fixtures/slots.json new file mode 100644 index 00000000..d9996d20 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/slots.json @@ -0,0 +1,271 @@ +{ + "blocks": [ + { + "id": "dict1", + "type": "dictionary", + "props": { + "data": { + "name": "status", + "entries": [ + { + "id": "e0", + "value": "open", + "label": "Open case" + }, + { + "id": "e1", + "value": "closed", + "label": "Closed" + } + ] + } + } + }, + { + "id": "dm", + "type": "dataModel", + "props": { + "data": { + "name": "customer", + "properties": [ + { + "id": "p1", + "name": "age", + "type": "number", + "array": false, + "optional": false + }, + { + "id": "p2", + "name": "tier", + "type": "string", + "enum": [ + "gold", + "silver" + ], + "array": false, + "optional": false + }, + { + "id": "p3", + "name": "name", + "type": "string", + "array": false, + "optional": false + }, + { + "id": "p4", + "name": "status", + "type": "relationship", + "target": "status", + "array": false, + "optional": false + }, + { + "id": "p6", + "name": "since", + "type": "date", + "array": false, + "optional": false + } + ] + } + }, + "children": [] + }, + { + "id": "dt", + "type": "decisionTable", + "props": { + "data": { + "hitPolicy": "first", + "inputs": [ + { + "id": "in_declared", + "name": "Age", + "field": "customer.age" + }, + { + "id": "in_dict", + "name": "Status", + "field": "customer.status" + }, + { + "id": "in_since", + "name": "Since", + "field": "customer.since" + }, + { + "id": "in_since_date", + "name": "Since date", + "field": "d(customer.since)" + }, + { + "id": "in_computed", + "name": "Next age", + "field": "customer.age + 1" + }, + { + "id": "in_condition", + "name": "Condition" + } + ], + "outputs": [ + { + "id": "out_dict", + "name": "Level", + "field": "customer.level", + "type": "status" + }, + { + "id": "out_number", + "name": "Score", + "field": "customer.score", + "type": "number" + }, + { + "id": "out_declared", + "name": "Tier", + "field": "customer.tier" + }, + { + "id": "out_union", + "name": "Band", + "field": "customer.band" + }, + { + "id": "out_untyped", + "name": "Label", + "field": "customer.label" + } + ], + "rules": [ + { + "_id": "r1", + "in_declared": "> 18", + "in_dict": "\"open\"", + "in_since": "", + "in_computed": "> 19", + "in_condition": "customer.age > 1", + "out_dict": "\"open\"", + "out_number": "1", + "out_declared": "\"gold\"", + "out_union": "\"a\"", + "out_untyped": "customer.name", + "in_since_date": "" + }, + { + "_id": "r2", + "in_declared": "", + "in_dict": "", + "in_since": "", + "in_computed": "", + "in_condition": "", + "out_dict": "\"closed\"", + "out_number": "2", + "out_declared": "\"silver\"", + "out_union": "\"b\"", + "out_untyped": "customer.name", + "in_since_date": "" + }, + { + "_id": "r3", + "in_declared": "", + "in_dict": "", + "in_since": "", + "in_computed": "", + "in_condition": "", + "out_dict": "", + "out_number": "", + "out_declared": "", + "out_union": "", + "out_untyped": "", + "in_since_date": "" + } + ] + } + }, + "children": [] + }, + { + "id": "ex_declared", + "type": "expression", + "props": { + "data": { + "key": "customer.tier", + "value": "\"gold\"" + } + } + }, + { + "id": "ex_computed", + "type": "expression", + "props": { + "data": { + "key": "customer.total", + "value": "customer.age * 2" + } + } + }, + { + "id": "as1", + "type": "assertion", + "props": { + "data": { + "output": "customer.isAdult", + "conditions": [ + { + "id": "c1", + "expression": "customer.age >= 18", + "operator": "and", + "depth": 0 + } + ] + } + }, + "children": [] + }, + { + "id": "m_declared", + "type": "match", + "props": { + "data": { + "key": "customer.status", + "arms": [ + { + "id": "a1", + "condition": "customer.age > 50", + "value": "\"open\"" + }, + { + "id": "a2", + "condition": "", + "value": "\"closed\"" + } + ] + } + } + }, + { + "id": "m_union", + "type": "match", + "props": { + "data": { + "key": "customer.group", + "arms": [ + { + "id": "b1", + "condition": "customer.age > 50", + "value": "\"senior\"" + }, + { + "id": "b2", + "condition": "", + "value": "\"kid\"" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/core/engine/tests/data/policy/fixtures/slots_graph.json b/core/engine/tests/data/policy/fixtures/slots_graph.json new file mode 100644 index 00000000..05782bf0 --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/slots_graph.json @@ -0,0 +1,216 @@ +{ + "imports": [ + "slots_graph_dicts.json" + ], + "nodes": [ + { + "id": "in", + "name": "in", + "type": "inputNode", + "content": { + "schema": "{\"type\": \"object\", \"properties\": {\"age\": {\"type\": \"number\"}, \"name\": {\"type\": \"string\"}}, \"required\": [\"age\", \"name\"]}" + } + }, + { + "id": "calc", + "name": "calc", + "type": "expressionNode", + "content": { + "passThrough": true, + "expressions": [ + { + "id": "row_total", + "key": "total", + "value": "age * 2" + }, + { + "id": "row_vip", + "key": "vip", + "value": "age > 60" + }, + { + "id": "row_extra", + "key": "extra", + "value": "name" + } + ] + } + }, + { + "id": "sw", + "name": "sw", + "type": "switchNode", + "content": { + "statements": [ + { + "id": "s1", + "condition": "age > 18" + } + ] + } + }, + { + "id": "dt", + "name": "dt", + "type": "decisionTableNode", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "c_field", + "name": "Age", + "field": "age" + }, + { + "id": "c_condition", + "name": "Condition" + } + ], + "outputs": [ + { + "id": "o_dict", + "name": "Tier", + "field": "tier", + "type": "customerTier" + }, + { + "id": "o_plain", + "name": "Score", + "field": "score" + }, + { + "id": "o_schema", + "name": "Total", + "field": "total" + }, + { + "id": "o_schema_dict", + "name": "Level", + "field": "level" + } + ], + "rules": [ + { + "_id": "r1", + "c_field": "> 18", + "c_condition": "age > 1", + "o_dict": "'VIP'", + "o_plain": "1", + "o_schema": "", + "o_schema_dict": "" + }, + { + "_id": "r2", + "c_field": "", + "c_condition": "", + "o_dict": "'STD'", + "o_plain": "2", + "o_schema": "", + "o_schema_dict": "" + } + ] + } + }, + { + "id": "dt_untyped", + "name": "dt_untyped", + "type": "decisionTableNode", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "u_age", + "name": "Age", + "field": "age" + } + ], + "outputs": [ + { + "id": "o_bool", + "name": "Triggered", + "field": "triggered" + }, + { + "id": "o_str", + "name": "Bucket", + "field": "bucket" + }, + { + "id": "o_mixed", + "name": "Mixed", + "field": "mixed" + } + ], + "rules": [ + { + "_id": "u1", + "u_age": "> 18", + "o_bool": "false", + "o_str": "'a'", + "o_mixed": "true" + }, + { + "_id": "u2", + "u_age": "> 60", + "o_bool": "true", + "o_str": "'b'", + "o_mixed": "'x'" + }, + { + "_id": "u3", + "u_age": "", + "o_bool": "", + "o_str": "", + "o_mixed": "" + } + ] + } + }, + { + "id": "out", + "name": "out", + "type": "outputNode", + "content": { + "schema": "{\"type\": \"object\", \"properties\": {\"total\": {\"type\": \"number\"}, \"vip\": {\"type\": \"boolean\"}, \"level\": {\"$dictionary\": \"customerTier\"}}, \"required\": [\"total\", \"vip\", \"level\"]}" + } + } + ], + "edges": [ + { + "id": "e1", + "sourceId": "in", + "targetId": "calc", + "sourceHandle": null + }, + { + "id": "e2", + "sourceId": "calc", + "targetId": "sw", + "sourceHandle": null + }, + { + "id": "e3", + "sourceId": "sw", + "targetId": "dt", + "sourceHandle": null + }, + { + "id": "e4", + "sourceId": "dt", + "targetId": "out", + "sourceHandle": null + }, + { + "id": "e5", + "sourceId": "sw", + "targetId": "dt_untyped", + "sourceHandle": null + }, + { + "id": "e6", + "sourceId": "dt_untyped", + "targetId": "out", + "sourceHandle": null + } + ] +} \ No newline at end of file diff --git a/core/engine/tests/data/policy/fixtures/slots_graph_dicts.json b/core/engine/tests/data/policy/fixtures/slots_graph_dicts.json new file mode 100644 index 00000000..c000c59c --- /dev/null +++ b/core/engine/tests/data/policy/fixtures/slots_graph_dicts.json @@ -0,0 +1,26 @@ +{ + "imports": [], + "blocks": [ + { + "id": "dict1", + "type": "dictionary", + "props": { + "data": { + "name": "customerTier", + "entries": [ + { + "id": "e0", + "value": "VIP", + "label": "Very important" + }, + { + "id": "e1", + "value": "STD", + "label": "Standard" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/core/engine/tests/data/policy/slots.toml b/core/engine/tests/data/policy/slots.toml new file mode 100644 index 00000000..29c9a510 --- /dev/null +++ b/core/engine/tests/data/policy/slots.toml @@ -0,0 +1,382 @@ +# Cursor scope per cell: kind, role, expected type and unary subject. +# Optional `text` (with `|` caret) checks the slot the editor gets there. + +policies = ["slots.json"] +graphs = ["slots_graph_dicts.json", "slots_graph.json"] + +# Policy decision table + +[[test]] +name = "input cell on a declared field is unary over the field" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_declared" +row = "r1" +kind = "unary" +role = "unary" +subject = "number" + +[[test]] +name = "input cell on a dictionary field offers labelled values" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_dict" +row = "r2" +kind = "unary" +subject = "status" +text = "|" +state = "unaryStart" +options = ["open=Open case", "closed=Closed"] + +[[test]] +name = "input cell on a declared date field is a real date" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_since" +row = "r1" +kind = "unary" +subject = "date" + +[[test]] +name = "input cell on d(field) is a real date" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_since_date" +row = "r1" +kind = "unary" +subject = "date" + +[[test]] +name = "input cell on a computed field is unary over its result" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_computed" +row = "r1" +kind = "unary" +subject = "number" + +[[test]] +name = "input column without a field is a condition" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "in_condition" +row = "r1" +kind = "standard" +role = "condition" +expected = "bool" + +[[test]] +name = "output cell with a dictionary type offers labelled values" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_dict" +row = "r3" +role = "value" +expected = "status" +text = "|" +state = "value" +options = ["open=Open case", "closed=Closed"] + +[[test]] +name = "output cell with a declared type" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_number" +row = "r3" +role = "value" +expected = "number" + +[[test]] +name = "output cell on a declared field expects the field type" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_declared" +row = "r3" +role = "value" +expected = '"gold" | "silver"' + +[[test]] +name = "undeclared output cell uses the sibling literal union" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_union" +row = "r3" +role = "value" +expected = '"a" | "b"' + +[[test]] +name = "policy tables keep the written union for every row" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_union" +row = "r1" +expected = '"a" | "b"' + +[[test]] +name = "output cell with non-literal siblings has no expectation" +policy = "slots.json" +block_id = "dt" +target = "cell" +id = "out_untyped" +row = "r3" +role = "value" +expected = "" + +[[test]] +name = "input column head is an expression" +policy = "slots.json" +block_id = "dt" +target = "head" +id = "in_computed" +kind = "standard" +role = "value" + +# Policy expression, assertion and match blocks + +[[test]] +name = "expression value expects the declared key type" +policy = "slots.json" +block_id = "ex_declared" +target = "expression" +id = "ex_declared" +role = "value" +expected = '"gold" | "silver"' + +[[test]] +name = "expression value with an undeclared key has no expectation" +policy = "slots.json" +block_id = "ex_computed" +target = "expression" +id = "ex_computed" +role = "value" +expected = "" + +[[test]] +name = "expression key is a path" +policy = "slots.json" +block_id = "ex_declared" +target = "expression_key" +role = "path" + +[[test]] +name = "assertion condition" +policy = "slots.json" +block_id = "as1" +target = "expression" +id = "c1" +role = "condition" +expected = "bool" + +[[test]] +name = "assertion output is a path" +policy = "slots.json" +block_id = "as1" +target = "assertion_output" +role = "path" + +[[test]] +name = "match target is a path" +policy = "slots.json" +block_id = "m_declared" +target = "match_target" +role = "path" + +[[test]] +name = "match arm condition" +policy = "slots.json" +block_id = "m_declared" +target = "expression" +id = "a1" +role = "condition" +expected = "bool" + +[[test]] +name = "match value on a declared key offers labelled values" +policy = "slots.json" +block_id = "m_declared" +target = "match_value" +id = "a1" +role = "value" +expected = "status" +text = "|" +state = "value" +options = ["open=Open case", "closed=Closed"] + +[[test]] +name = "match value on an undeclared key ignores sibling arms" +policy = "slots.json" +block_id = "m_union" +target = "match_value" +id = "b2" +role = "value" +expected = "" + +[[test]] +name = "data model has no scope" +policy = "slots.json" +block_id = "dm" +target = "data_model_name" + +[[test]] +name = "unknown block has no scope" +policy = "slots.json" +block_id = "missing" +target = "expression" +id = "x" + +# Graph + +[[test]] +name = "graph expression row not wired to an output has no expectation" +policy = "slots_graph.json" +block_id = "calc" +target = "expression" +id = "row_total" +role = "value" +expected = "" + +[[test]] +name = "graph expression row outside the schema has no expectation" +policy = "slots_graph.json" +block_id = "calc" +target = "expression" +id = "row_extra" +role = "value" +expected = "" + +[[test]] +name = "graph expression key is a path" +policy = "slots_graph.json" +block_id = "calc" +target = "expression_key" +role = "path" + +[[test]] +name = "graph transform input is an expression" +policy = "slots_graph.json" +block_id = "calc" +target = "transform_input" +role = "value" + +[[test]] +name = "switch node has no transform input" +policy = "slots_graph.json" +block_id = "sw" +target = "transform_input" + +[[test]] +name = "graph switch statement is a condition" +policy = "slots_graph.json" +block_id = "sw" +target = "expression" +id = "s1" +role = "condition" +expected = "bool" + +[[test]] +name = "graph table input cell is unary over the field" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "c_field" +row = "r1" +kind = "unary" +subject = "number" + +[[test]] +name = "graph table condition column" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "c_condition" +row = "r1" +role = "condition" + +[[test]] +name = "graph output cell with a dictionary type offers labelled values" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "o_dict" +row = "r1" +role = "value" +expected = "customerTier" +text = "|" +state = "value" +options = ["VIP=Very important", "STD=Standard"] + +[[test]] +name = "graph output cell falls back to the output schema" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "o_schema" +row = "r1" +expected = "number" + +[[test]] +name = "graph output schema dictionary offers labelled values" +policy = "slots_graph.json" +block_id = "dt" +target = "cell" +id = "o_schema_dict" +row = "r1" +expected = "customerTier" +text = "|" +state = "value" +options = ["VIP=Very important", "STD=Standard"] + +[[test]] +name = "graph undeclared bool column uses sibling literals" +policy = "slots_graph.json" +block_id = "dt_untyped" +target = "cell" +id = "o_bool" +row = "u3" +expected = "bool" + +[[test]] +name = "graph undeclared string column uses the sibling union" +policy = "slots_graph.json" +block_id = "dt_untyped" +target = "cell" +id = "o_str" +row = "u3" +expected = '"a" | "b"' + +[[test]] +name = "graph sibling union excludes the edited row" +policy = "slots_graph.json" +block_id = "dt_untyped" +target = "cell" +id = "o_str" +row = "u1" +expected = '"b"' + +[[test]] +name = "graph mixed sibling literals have no expectation" +policy = "slots_graph.json" +block_id = "dt_untyped" +target = "cell" +id = "o_mixed" +row = "u3" +expected = "" + +[[test]] +name = "graph column head is a path" +policy = "slots_graph.json" +block_id = "dt" +target = "head" +id = "o_dict" +role = "path" diff --git a/core/engine/tests/policy.rs b/core/engine/tests/policy.rs index d22203fb..d584623a 100644 --- a/core/engine/tests/policy.rs +++ b/core/engine/tests/policy.rs @@ -249,7 +249,10 @@ impl DependencyCase { } let mut nodes: Vec<(String, bool, bool)> = Vec::new(); - Self::collect(&ws.dependencies(&self.target), &mut nodes); + Self::collect( + &ws.dependencies_scoped(&self.target, self.policy.as_deref()), + &mut nodes, + ); let paths: Vec<&str> = nodes.iter().map(|(p, _, _)| p.as_str()).collect(); let find = |path: &str| nodes.iter().find(|(p, _, _)| p == path); @@ -2875,7 +2878,10 @@ fn expression_diagnostics_distinguish_key_from_value() { .expect("expected an InvalidWritePath diagnostic on the statement key"); assert_eq!(key_diag.location.expression_id.as_deref(), Some("s1")); assert!( - matches!(&key_diag.location.target, Some(CursorTarget::ExpressionKey)), + matches!( + &key_diag.location.target, + Some(CursorTarget::ExpressionKey { .. }) + ), "key diagnostic must target the expression key, got {:?}", key_diag.location.target ); @@ -3011,7 +3017,7 @@ fn input_override_on_expression_key_carries_key_target() { .find(|d| format!("{:?}", d.code) == "InputOverride") .expect("expected InputOverride"); assert!( - matches!(&d.location.target, Some(CursorTarget::ExpressionKey)), + matches!(&d.location.target, Some(CursorTarget::ExpressionKey { .. })), "InputOverride must target the expression key, got {:?}", d.location.target ); @@ -3088,6 +3094,60 @@ fn unrelated_policies_do_not_cross_flag_writes() { } } +#[test] +fn duplicate_writer_is_reported_on_the_importing_policy() { + let writer = |block: &str, imports: &[&str]| { + json!({ + "imports": imports, + "blocks": [ + { "id": block, "type": "expression", "props": { "data": json!({ + "key": "decision", "value": "\"x\"" + })}} + ] + }) + }; + let duplicates = |ws: &PolicyWorkspace, path: &str| -> Vec> { + ws.diagnostics(path) + .into_iter() + .filter(|d| d.code == zen_engine::policy::DiagnosticCode::DuplicateWriter) + .map(|d| d.location.block_id.map(|b| b.to_string())) + .collect() + }; + + for (importer, imported, entry) in [ + ("airline/rules/apu", "airline/test", "airline/entry"), + ("zz/importer", "aa/imported", "mm/entry"), + ] { + let mut ws = PolicyWorkspace::new(); + ws.set_policy( + imported, + serde_json::from_value(writer("imported-block", &[])).unwrap(), + ); + ws.set_policy( + importer, + serde_json::from_value(writer("importer-block", &[imported])).unwrap(), + ); + ws.set_policy( + entry, + serde_json::from_value(json!({ "imports": [importer], "blocks": [] })).unwrap(), + ); + + assert_eq!( + duplicates(&ws, importer), + vec![Some("importer-block".to_string())], + "[{importer}] the importing policy owns the conflict on its own block", + ); + assert!( + duplicates(&ws, imported).is_empty(), + "[{imported}] the imported policy cannot see its importer's write", + ); + assert!( + duplicates(&ws, entry).is_empty(), + "[{entry}] a policy importing the offender must not repeat its error", + ); + } +} + #[test] fn disjoint_nested_writes_across_blocks_merge_at_runtime() { let doc = json!({ @@ -4915,4 +4975,92 @@ fn completions_offered_for_empty_and_trailing_space_sources() { partial.iter().any(|l| l == "customer"), "cursor past trimmed source should offer scope completions: {partial:?}" ); + assert!( + !empty.iter().chain(&partial).any(|l| l == "$root"), + "policies have no $root: {empty:?}" + ); +} + +#[test] +fn completions_ignore_declarations_in_unrelated_policies() { + let global_model = |props: serde_json::Value| { + json!({ "id": "dm", "type": "dataModel", "props": { "data": { + "name": "inputs", "scope": "global", "properties": props + } } }) + }; + let b = json!({ + "blocks": [ + global_model(json!([ + { "id": "p1", "name": "score", "type": "number", "array": false, "optional": false } + ])), + { "id": "e", "type": "expression", "props": { "data": { "key": "status", "value": "" } } } + ] + }); + let a = json!({ + "blocks": [ + global_model(json!([ + { "id": "p1", "name": "status", "type": "string", "array": false, "optional": false } + ])) + ] + }); + + let labels = |ws: &PolicyWorkspace| -> Vec { + ws.completions(&Cursor { + policy_path: Arc::from("b.json"), + block_id: Arc::from("e"), + pos: 0, + target: CursorTarget::Expression { id: Arc::from("e") }, + }) + .into_iter() + .map(|c| c.label) + .collect() + }; + + let mut ws = PolicyWorkspace::new(); + ws.set_policy("b.json", serde_json::from_value(b).unwrap()); + let alone = labels(&ws); + assert!(alone.iter().any(|l| l == "score"), "{alone:?}"); + + ws.set_policy("a.json", serde_json::from_value(a).unwrap()); + let with_unrelated = labels(&ws); + assert!( + with_unrelated.iter().any(|l| l == "score"), + "{with_unrelated:?}" + ); +} + +#[test] +fn prepare_rename_on_collect_output_head() { + let doc = json!({ "blocks": [ + { "id": "dm", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [ + { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } + ] } } }, + { "id": "dt", "type": "decisionTable", "props": { "data": { + "hitPolicy": "first", + "inputs": [{ "id": "i1", "name": "", "field": "customer.age" }], + "outputs": [{ "id": "o1", "name": "", "field": "customer.tags[]" }], + "rules": [{ "_id": "r1", "i1": "> 1", "o1": "\"a\"" }] + } } } + ] }); + let mut ws = PolicyWorkspace::new(); + ws.set_policy("p", serde_json::from_value(doc).unwrap()); + + let prepared = ws + .prepare_rename(&Cursor { + policy_path: Arc::from("p"), + block_id: Arc::from("dt"), + pos: 11, + target: CursorTarget::DecisionTableHead { + col: Arc::from("o1"), + }, + }) + .expect("collect head is renamable"); + assert_eq!( + prepared.target, + zen_engine::policy::RenameTarget::Field { + entity: Arc::from("customer"), + field: Arc::from("tags"), + } + ); + assert_eq!(prepared.span, (9, 13)); } diff --git a/core/engine/tests/policy_dictionary.rs b/core/engine/tests/policy_dictionary.rs index cbc4636e..b26c1fb7 100644 --- a/core/engine/tests/policy_dictionary.rs +++ b/core/engine/tests/policy_dictionary.rs @@ -1,9 +1,6 @@ use serde_json::json; use std::sync::Arc; -use zen_engine::policy::{ - Cursor, CursorTarget, EvaluateRequest, EvaluationError, PolicyWorkspace, ScopeRequest, -}; -use zen_expression::nl::{EditHint, NlTokenKind}; +use zen_engine::policy::{EvaluateRequest, EvaluationError, PolicyWorkspace, ScopeRequest}; use zen_expression::variable::Variable; fn dictionary_block(id: &str, name: &str, entries: &[(&str, &str)]) -> serde_json::Value { @@ -276,236 +273,6 @@ fn dictionaries_query_exposes_labels() { assert_eq!(dict.entries[0].label.as_ref(), "Very important"); } -#[test] -fn nl_tokenize_uses_dictionary_labels() { - let ws = workspace_with(vec![ - tier_dictionary(), - json!({ - "id": "dm1", - "type": "dataModel", - "props": { "data": { - "name": "customer", - "properties": [ - { "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false } - ] - }} - }), - json!({ - "id": "dt1", - "type": "decisionTable", - "props": { "data": { - "hitPolicy": "first", - "inputs": [ { "id": "in1", "name": "Tier", "field": "customer.tier" } ], - "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ], - "rules": [ { "_id": "row1", "in1": "'VIP'", "out1": "'vip'" } ] - }} - }), - ]); - - let cursor = Cursor { - policy_path: "main".into(), - block_id: "dt1".into(), - pos: 0, - target: CursorTarget::DecisionTableCell { - row: "row1".into(), - col: "in1".into(), - }, - }; - let result = ws.nl_tokenize(&cursor, "'VIP'").expect("cursor resolves"); - - let str_tok = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Str { .. })) - .expect("string token present"); - let Some(EditHint::Select { options }) = str_tok.hint else { - panic!("expected select hint, got {:?}", str_tok.hint); - }; - let options = &result.enums[options as usize]; - let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect(); - assert_eq!(labels, vec!["Very important", "Standard"]); - let sources: Vec<&str> = options.iter().filter_map(|o| o.source.as_deref()).collect(); - assert_eq!(sources, vec!["\"VIP\"", "\"STD\""]); -} - -#[test] -fn nl_tokenize_empty_cell_exposes_labeled_subject_options() { - let ws = workspace_with(vec![ - tier_dictionary(), - json!({ - "id": "dm1", - "type": "dataModel", - "props": { "data": { - "name": "customer", - "properties": [ - { "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false } - ] - }} - }), - json!({ - "id": "dt1", - "type": "decisionTable", - "props": { "data": { - "hitPolicy": "first", - "inputs": [ { "id": "in1", "name": "Tier", "field": "customer.tier" } ], - "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ], - "rules": [ { "_id": "row1", "in1": "", "out1": "'vip'" } ] - }} - }), - ]); - - let cursor = Cursor { - policy_path: "main".into(), - block_id: "dt1".into(), - pos: 0, - target: CursorTarget::DecisionTableCell { - row: "row1".into(), - col: "in1".into(), - }, - }; - let result = ws.nl_tokenize(&cursor, "").expect("cursor resolves"); - - let options = result.subject_options.expect("subject options present"); - let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect(); - assert_eq!(labels, vec!["Very important", "Standard"]); - let sources: Vec<&str> = options.iter().filter_map(|o| o.source.as_deref()).collect(); - assert_eq!(sources, vec!["\"VIP\"", "\"STD\""]); - - let batch = ws.nl("main"); - let cell = batch - .iter() - .find(|e| { - e.block_id.as_ref() == "dt1" - && matches!(&e.target, CursorTarget::DecisionTableCell { col, .. } if col.as_ref() == "in1") - }) - .expect("batch projection for the table cell"); - let batch_options = cell - .result - .subject_options - .as_ref() - .expect("batch projection carries subject options"); - let batch_labels: Vec<&str> = batch_options.iter().map(|o| o.label.as_str()).collect(); - assert_eq!(batch_labels, vec!["Very important", "Standard"]); -} - -#[test] -fn nl_tokenize_closure_membership_gets_enum_multiselect() { - let ws = workspace_with(vec![ - tier_dictionary(), - json!({ - "id": "dm1", - "type": "dataModel", - "props": { "data": { - "name": "customer", - "properties": [ - { "id": "p1", "name": "tags", "type": "relationship", "target": "customerTier", "array": true, "optional": false } - ] - }} - }), - json!({ - "id": "dt1", - "type": "decisionTable", - "props": { "data": { - "hitPolicy": "first", - "inputs": [ { "id": "in1", "name": "Tags", "field": "customer.tags" } ], - "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ], - "rules": [ { "_id": "row1", "in1": "all(['VIP'], # in $)", "out1": "'vip'" } ] - }} - }), - ]); - - let cursor = Cursor { - policy_path: "main".into(), - block_id: "dt1".into(), - pos: 0, - target: CursorTarget::DecisionTableCell { - row: "row1".into(), - col: "in1".into(), - }, - }; - let result = ws - .nl_tokenize(&cursor, "all(['VIP'], # in $)") - .expect("cursor resolves"); - - let list = result - .tokens - .iter() - .find(|t| matches!(&t.token, NlTokenKind::EnumList { .. })) - .expect("array literal projects as enum list"); - let NlTokenKind::EnumList { selected } = &list.token else { - unreachable!(); - }; - assert_eq!(selected.as_slice(), [Box::from("VIP")]); - let Some(EditHint::MultiSelect { options }) = list.hint else { - panic!("expected multi-select hint, got {:?}", list.hint); - }; - let options = &result.enums[options as usize]; - let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect(); - assert_eq!(labels, vec!["Very important", "Standard"]); -} - -#[test] -fn nl_tokenize_reversed_membership_and_contains_get_enum_hints() { - let ws = workspace_with(vec![ - tier_dictionary(), - json!({ - "id": "dm1", - "type": "dataModel", - "props": { "data": { - "name": "customer", - "properties": [ - { "id": "p1", "name": "tags", "type": "relationship", "target": "customerTier", "array": true, "optional": false } - ] - }} - }), - json!({ - "id": "dt1", - "type": "decisionTable", - "props": { "data": { - "hitPolicy": "first", - "inputs": [ { "id": "in1", "name": "Tags", "field": "customer.tags" } ], - "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ], - "rules": [ { "_id": "row1", "in1": "'VIP' in $", "out1": "'vip'" } ] - }} - }), - ]); - - let cursor = Cursor { - policy_path: "main".into(), - block_id: "dt1".into(), - pos: 0, - target: CursorTarget::DecisionTableCell { - row: "row1".into(), - col: "in1".into(), - }, - }; - - let assert_labeled_select = |result: zen_expression::nl::NlResult| { - let str_tok = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Str { .. })) - .expect("string token present"); - let Some(EditHint::Select { options }) = str_tok.hint else { - panic!("expected select hint, got {:?}", str_tok.hint); - }; - let labels: Vec<&str> = result.enums[options as usize] - .iter() - .map(|o| o.label.as_str()) - .collect(); - assert_eq!(labels, vec!["Very important", "Standard"]); - }; - - assert_labeled_select( - ws.nl_tokenize(&cursor, "'VIP' in $") - .expect("cursor resolves"), - ); - assert_labeled_select( - ws.nl_tokenize(&cursor, "contains($, 'VIP')") - .expect("cursor resolves"), - ); -} - #[test] fn dictionary_block_round_trips_through_wire_format() { let block_json = tier_dictionary(); diff --git a/core/engine/tests/policy_nl.rs b/core/engine/tests/policy_nl.rs deleted file mode 100644 index 1cff1e30..00000000 --- a/core/engine/tests/policy_nl.rs +++ /dev/null @@ -1,231 +0,0 @@ -use serde_json::json; -use zen_engine::policy::{ - Cursor, CursorTarget, ExpressionKind, NlExpression, PolicyDocument, PolicyWorkspace, -}; -use zen_expression::nl::{EditHint, NlTokenKind, OpSym, TypeTag}; - -fn workspace() -> PolicyWorkspace { - let doc: PolicyDocument = serde_json::from_value(json!({ - "blocks": [ - { - "id": "dm", - "type": "dataModel", - "props": { "data": { - "name": "customer", - "properties": [ - { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }, - { "id": "p2", "name": "tier", "type": "string", "enum": ["gold", "silver", "bronze"], "array": false, "optional": false } - ] - } }, - "children": [] - }, - { - "id": "assert1", - "type": "assertion", - "props": { "data": { - "output": "customer.isAdult", - "conditions": [ - { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } - ] - } }, - "children": [] - }, - { - "id": "dt1", - "type": "decisionTable", - "props": { "data": { - "hitPolicy": "first", - "inputs": [ - { "id": "in1", "name": "Age", "field": "customer.age" }, - { "id": "in2", "name": "Tier", "field": "customer.tier" } - ], - "outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ], - "rules": [ { "_id": "row1", "in1": "> 18", "in2": "'gold'", "out1": "'vip'" } ] - } }, - "children": [] - } - ] - })) - .expect("valid policy fixture"); - - let mut ws = PolicyWorkspace::new(); - ws.set_policy("policy", doc); - ws -} - -fn find<'a>(results: &'a [NlExpression], pred: impl Fn(&NlExpression) -> bool) -> &'a NlExpression { - results - .iter() - .find(|e| pred(e)) - .expect("expression present") -} - -fn kinds(e: &NlExpression) -> Vec { - e.result.tokens.iter().map(|t| t.token.clone()).collect() -} - -#[test] -fn assertion_condition_resolves_field_type() { - let ws = workspace(); - let results = ws.nl("policy"); - - let condition = find( - &results, - |e| matches!(&e.target, CursorTarget::Expression { id } if id.as_ref() == "c1"), - ); - - assert_eq!(condition.kind, ExpressionKind::Standard); - assert_eq!(condition.block_id.as_ref(), "assert1"); - assert_eq!( - kinds(condition), - vec![ - NlTokenKind::Field { - path: vec!["customer".into(), "age".into()], - ty: TypeTag::Number, - }, - NlTokenKind::Op { - sym: OpSym::Gte, - implied: false, - between: false, - }, - NlTokenKind::Number { value: "18".into() }, - ] - ); -} - -#[test] -fn decision_table_input_cell_is_unary_with_resolved_subject() { - let ws = workspace(); - let results = ws.nl("policy"); - - let cell = find(&results, |e| { - matches!( - &e.target, - CursorTarget::DecisionTableCell { row, col } - if row.as_ref() == "row1" && col.as_ref() == "in1" - ) - }); - - assert_eq!(cell.kind, ExpressionKind::Unary); - let k = kinds(cell); - assert_eq!( - k[0], - NlTokenKind::Op { - sym: OpSym::Gt, - implied: true, - between: false, - } - ); - assert_eq!(k[1], NlTokenKind::Number { value: "18".into() }); -} - -#[test] -fn decision_table_output_cell_is_standard() { - let ws = workspace(); - let results = ws.nl("policy"); - - let cell = find(&results, |e| { - matches!( - &e.target, - CursorTarget::DecisionTableCell { row, col } - if row.as_ref() == "row1" && col.as_ref() == "out1" - ) - }); - - assert_eq!(cell.kind, ExpressionKind::Standard); - assert!(matches!(kinds(cell).as_slice(), [NlTokenKind::Str { .. }])); -} - -fn cell_cursor(col: &str) -> Cursor { - Cursor { - policy_path: "policy".into(), - block_id: "dt1".into(), - pos: 0, - target: CursorTarget::DecisionTableCell { - row: "row1".into(), - col: col.into(), - }, - } -} - -#[test] -fn nl_tokenize_resolves_unary_enum_subject() { - let ws = workspace(); - let result = ws - .nl_tokenize(&cell_cursor("in2"), "'gold'") - .expect("cursor resolves"); - - let str_tok = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Str { .. })) - .expect("string token present"); - assert_eq!(str_tok.hint, Some(EditHint::Select { options: 0 })); - let labels: Vec<&str> = result.enums[0].iter().map(|o| o.label.as_str()).collect(); - assert_eq!(labels, vec!["gold", "silver", "bronze"]); - assert!(result.enums[0] - .iter() - .all(|o| o.source.as_deref() == Some(format!("\"{}\"", o.label).as_str()))); -} - -#[test] -fn nl_tokenize_projects_live_text_over_stored_cell() { - let ws = workspace(); - let result = ws - .nl_tokenize(&cell_cursor("in1"), "< 21") - .expect("cursor resolves"); - - let tokens: Vec = result.tokens.iter().map(|t| t.token.clone()).collect(); - assert_eq!( - tokens[0], - NlTokenKind::Op { - sym: OpSym::Lt, - implied: true, - between: false, - } - ); - assert_eq!(tokens[1], NlTokenKind::Number { value: "21".into() }); -} - -#[test] -fn nl_tokenize_standard_expression_uses_policy_scope() { - let ws = workspace(); - let cursor = Cursor { - policy_path: "policy".into(), - block_id: "assert1".into(), - pos: 0, - target: CursorTarget::Expression { id: "c1".into() }, - }; - let result = ws - .nl_tokenize(&cursor, "customer.age >= 21") - .expect("cursor resolves"); - - let tokens: Vec = result.tokens.iter().map(|t| t.token.clone()).collect(); - assert_eq!( - tokens[0], - NlTokenKind::Field { - path: vec!["customer".into(), "age".into()], - ty: TypeTag::Number, - } - ); -} - -#[test] -fn decision_table_input_head_is_projected() { - let ws = workspace(); - let results = ws.nl("policy"); - - let head = find( - &results, - |e| matches!(&e.target, CursorTarget::DecisionTableHead { col } if col.as_ref() == "in1"), - ); - - assert_eq!(head.kind, ExpressionKind::Standard); - assert_eq!( - kinds(head), - vec![NlTokenKind::Field { - path: vec!["customer".into(), "age".into()], - ty: TypeTag::Number, - }] - ); -} diff --git a/core/engine/tests/policy_output_types.rs b/core/engine/tests/policy_output_types.rs index f934d58d..6e37358e 100644 --- a/core/engine/tests/policy_output_types.rs +++ b/core/engine/tests/policy_output_types.rs @@ -1,6 +1,5 @@ use serde_json::json; -use zen_engine::policy::{Cursor, CursorTarget, NlExpression, PolicyWorkspace, ScopeRequest}; -use zen_expression::nl::{EditHint, NlTokenKind}; +use zen_engine::policy::{PolicyWorkspace, ScopeRequest}; fn tier_dictionary() -> serde_json::Value { json!({ @@ -162,82 +161,6 @@ fn declared_type_narrows_output_schema() { ); } -fn output_cell<'a>(results: &'a [NlExpression], row: &str) -> &'a NlExpression { - results - .iter() - .find(|e| { - matches!( - &e.target, - CursorTarget::DecisionTableCell { row: r, col } - if r.as_ref() == row && col.as_ref() == "out1" - ) - }) - .expect("output cell projected") -} - -#[test] -fn nl_output_cell_gets_enum_select_with_labels() { - let ws = workspace_with(vec![ - tier_dictionary(), - table_with_output("customerTier", &["'VIP'"]), - ]); - let results = ws.nl("main"); - let cell = output_cell(&results, "row0"); - - let token = &cell.result.tokens[0]; - assert!(matches!(token.token, NlTokenKind::Str { .. })); - let EditHint::Select { options } = token.hint.clone().expect("select hint") else { - panic!("expected select hint, got {:?}", token.hint); - }; - let options = &cell.result.enums[options as usize]; - assert_eq!(options[0].label, "Very important"); - assert_eq!(options[0].source.as_deref(), Some("\"VIP\"")); - - let subject_options = cell.result.subject_options.as_ref().expect("options"); - assert_eq!(subject_options.len(), 2); -} - -#[test] -fn nl_array_output_cell_gets_multiselect() { - let ws = workspace_with(vec![ - tier_dictionary(), - table_with_output("customerTier[]", &["['VIP', 'STD']"]), - ]); - let results = ws.nl("main"); - let cell = output_cell(&results, "row0"); - - let token = &cell.result.tokens[0]; - assert!(matches!(token.token, NlTokenKind::EnumList { .. })); - assert!(matches!(token.hint, Some(EditHint::MultiSelect { .. }))); -} - -#[test] -fn nl_tokenize_live_output_cell_uses_declared_type() { - let ws = workspace_with(vec![ - tier_dictionary(), - table_with_output("customerTier", &["'VIP'"]), - ]); - let result = ws - .nl_tokenize( - &Cursor { - policy_path: "main".into(), - block_id: "dt1".into(), - pos: 0, - target: CursorTarget::DecisionTableCell { - row: "row0".into(), - col: "out1".into(), - }, - }, - "'STD'", - ) - .expect("tokenized"); - - let token = &result.tokens[0]; - assert!(matches!(token.hint, Some(EditHint::Select { .. }))); - let subject_options = result.subject_options.as_ref().expect("options"); - assert_eq!(subject_options[1].label, "Standard"); -} - #[test] fn untyped_columns_keep_inferred_behavior() { let ws = workspace_with(vec![table_with_output("", &["'a'", "'b'"])]); @@ -246,11 +169,6 @@ fn untyped_columns_keep_inferred_behavior() { !diagnostics.iter().any(|d| d.contains("TypeMismatch")), "got: {diagnostics:?}" ); - - let results = ws.nl("main"); - let cell = output_cell(&results, "row0"); - assert!(cell.result.tokens[0].hint.is_none()); - assert!(cell.result.subject_options.is_none()); } fn expression_block(id: &str, key: &str, value: &str) -> serde_json::Value { diff --git a/core/engine/tests/policy_toml.rs b/core/engine/tests/policy_toml.rs index c91d194d..c080317c 100644 --- a/core/engine/tests/policy_toml.rs +++ b/core/engine/tests/policy_toml.rs @@ -1,11 +1,12 @@ use serde::Deserialize; use std::collections::BTreeMap; use std::sync::Arc; +use zen_engine::model::DecisionContent; use zen_engine::policy::{ Cursor, CursorTarget, EvaluateRequest, PolicyWorkspace, ReferenceKind, RenameTarget, ScopeRequest, }; -use zen_expression::variable::Variable; +use zen_expression::variable::{Variable, VariableType}; const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/policy/fixtures/"); @@ -21,6 +22,16 @@ fn build_workspace(policies: &[String]) -> PolicyWorkspace { ws } +fn load_graphs(ws: &mut PolicyWorkspace, graphs: &[String]) { + for path in graphs { + let raw = std::fs::read_to_string(format!("{FIXTURES_DIR}{path}")) + .unwrap_or_else(|e| panic!("cannot read fixture {path}: {e}")); + let doc: DecisionContent = serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("cannot deserialize fixture {path}: {e}")); + ws.set_document(path.as_str(), doc); + } +} + fn toml_to_json(value: &toml::Value) -> serde_json::Value { serde_json::to_value(value).expect("toml converts to json") } @@ -317,6 +328,8 @@ fn run_prepare_rename(file_name: &str, toml_data: &str) { #[derive(Debug, Deserialize)] struct CompletionsFile { policies: Vec, + #[serde(default)] + graphs: Vec, test: Vec, } @@ -329,6 +342,8 @@ struct CompletionsCase { pos: u32, #[serde(default)] head: bool, + #[serde(default)] + key: bool, row: Option, #[serde(default)] includes: Vec, @@ -339,11 +354,14 @@ struct CompletionsCase { fn run_completions(file_name: &str, toml_data: &str) { let file: CompletionsFile = toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}")); - let ws = build_workspace(&file.policies); + let mut ws = build_workspace(&file.policies); + load_graphs(&mut ws, &file.graphs); for test in &file.test { let ctx = format!("[{file_name}:{}]", test.name); - let target = if test.head { + let target = if test.key { + CursorTarget::ExpressionKey { id: None } + } else if test.head { CursorTarget::DecisionTableHead { col: Arc::from(test.expression_id.as_str()), } @@ -380,6 +398,122 @@ fn run_completions(file_name: &str, toml_data: &str) { } } +#[derive(Debug, Deserialize)] +struct SlotsFile { + policies: Vec, + #[serde(default)] + graphs: Vec, + test: Vec, +} + +#[derive(Debug, Deserialize)] +struct SlotsCase { + name: String, + policy: String, + block_id: String, + target: String, + id: Option, + row: Option, + kind: Option, + role: Option, + expected: Option, + subject: Option, + text: Option, + state: Option, + options: Option>, +} + +impl SlotsCase { + fn cursor(&self) -> Cursor { + let id = || Arc::from(self.id.as_deref().expect("target id")); + let target = match self.target.as_str() { + "expression" => CursorTarget::Expression { id: id() }, + "cell" => CursorTarget::DecisionTableCell { + row: Arc::from(self.row.as_deref().expect("row")), + col: id(), + }, + "head" => CursorTarget::DecisionTableHead { col: id() }, + "expression_key" => CursorTarget::ExpressionKey { id: None }, + "assertion_output" => CursorTarget::AssertionOutput, + "match_target" => CursorTarget::MatchTarget, + "match_value" => CursorTarget::MatchValue { id: id() }, + "transform_input" => CursorTarget::TransformInput, + "data_model_name" => CursorTarget::DataModelName, + other => panic!("unknown target {other}"), + }; + Cursor { + policy_path: Arc::from(self.policy.as_str()), + block_id: Arc::from(self.block_id.as_str()), + pos: 0, + target, + } + } + + fn check(&self, ws: &PolicyWorkspace) { + let ctx = format!("[slots.toml:{}]", self.name); + let mut cursor = self.cursor(); + let Some(scope) = ws.cursor_scope(&cursor) else { + assert!(self.kind.is_none(), "{ctx} expected a scope, got none"); + return; + }; + let render = |t: Option| t.map(|t| t.to_string()).unwrap_or_default(); + let actual = [ + ("kind", format!("{:?}", scope.kind).to_lowercase()), + ( + "role", + serde_json::to_value(scope.role) + .unwrap() + .as_str() + .unwrap() + .to_string(), + ), + ("expected", render(scope.expected.clone())), + ("subject", render(scope.subject_type())), + ]; + let wanted = [&self.kind, &self.role, &self.expected, &self.subject]; + for ((field, actual), wanted) in actual.iter().zip(wanted) { + if let Some(wanted) = wanted { + assert_eq!(actual, wanted, "{ctx} {field}"); + } + } + let Some(text) = &self.text else { + return; + }; + let caret = text.find('|').expect("caret"); + let source = text.replacen('|', "", 1); + cursor.pos = source[..caret].encode_utf16().count() as u32; + let slot = ws.slot(&cursor, &source).expect("slot").slot; + if let Some(state) = &self.state { + assert_eq!( + &serde_json::to_value(slot.state).unwrap(), + state, + "{ctx} state" + ); + } + if let Some(options) = &self.options { + let actual: Vec = slot + .options + .iter() + .map(|o| match o.label == o.value { + true => o.value.clone(), + false => format!("{}={}", o.value, o.label), + }) + .collect(); + assert_eq!(&actual, options, "{ctx} options"); + } + } +} + +fn run_slots(file_name: &str, toml_data: &str) { + let file: SlotsFile = + toml::from_str(toml_data).unwrap_or_else(|e| panic!("cannot parse {file_name}: {e}")); + let mut ws = build_workspace(&file.policies); + load_graphs(&mut ws, &file.graphs); + for test in &file.test { + test.check(&ws); + } +} + #[derive(Debug, Deserialize)] struct EntitiesFile { policies: Vec, @@ -608,3 +742,8 @@ fn entities_merge_multi_policy_toml_cases() { include_str!("data/policy/entities_merge_multi_policy.toml"), ); } + +#[test] +fn slots_toml_cases() { + run_slots("slots.toml", include_str!("data/policy/slots.toml")); +} diff --git a/core/engine/tests/workspace_graph.rs b/core/engine/tests/workspace_graph.rs index 88e5286d..f137c2bc 100644 --- a/core/engine/tests/workspace_graph.rs +++ b/core/engine/tests/workspace_graph.rs @@ -877,30 +877,6 @@ fn catch_all_row_covers_table_and_empty_cell_makes_column_nullable() { ); } -#[test] -fn nl_projects_graph_expressions() { - let mut ws = Workspace::new(); - let switch = node( - "sw", - "switchNode", - json!({ - "hitPolicy": "first", - "statements": [{ "id": "s1", "condition": "age > 18" }] - }), - ); - ws.set_document( - "g", - document(linear_graph( - Some(person_schema()), - vec![switch, expression_node("calc", &[("total", "age * 2")])], - )), - ); - let projected = ws.nl("g"); - let block_ids: Vec<&str> = projected.iter().map(|e| e.block_id.as_ref()).collect(); - assert!(block_ids.contains(&"sw"), "{block_ids:?}"); - assert!(block_ids.contains(&"calc"), "{block_ids:?}"); -} - #[test] fn unreachable_node_is_hinted() { let mut ws = Workspace::new(); @@ -999,13 +975,18 @@ fn graph_property_rename_rewrites_writers_and_readers() { } #[test] -fn rename_of_unwritten_property_is_refused() { +fn input_property_rename_rewrites_readers_and_schema() { + use zen_engine::policy::{EngineEdit, ReferenceKind, RenameTarget}; + let mut ws = Workspace::new(); ws.set_document( "g", document(linear_graph( Some(person_schema()), - vec![expression_node("calc", &[("total", "age * 2")])], + vec![expression_node( + "calc", + &[("total", "age * 2"), ("adult", "age >= 18")], + )], )), ); let cursor = Cursor { @@ -1016,7 +997,85 @@ fn rename_of_unwritten_property_is_refused() { id: "calc-e0".into(), }, }; - assert!(ws.prepare_rename(&cursor).is_none()); + let prepared = ws.prepare_rename(&cursor).expect("prepare rename"); + let RenameTarget::GraphProperty { document, path } = &prepared.target else { + panic!("unexpected target {:?}", prepared.target); + }; + assert_eq!(document.as_ref(), "g"); + assert_eq!(path.as_ref(), "age"); + assert_eq!(prepared.span, (0, 3)); + + let sites = ws.references(&prepared.target); + let declared = sites + .iter() + .filter(|s| s.kind == ReferenceKind::DataModel) + .count(); + let reads = sites + .iter() + .filter(|s| s.kind == ReferenceKind::ExpressionRead) + .count(); + assert_eq!((declared, reads), (1, 2), "{sites:?}"); + + let edits = ws.rename(&prepared.target, "years"); + let rendered: Vec = edits + .iter() + .map(|e| serde_json::to_string(e).unwrap()) + .collect(); + let calc = rendered + .iter() + .find(|r| r.contains("\"calc\"")) + .expect("calc edit"); + assert!( + calc.contains("years * 2") && calc.contains("years >= 18"), + "{calc}" + ); + let input = rendered + .iter() + .find(|r| r.contains("\"in\"")) + .expect("schema edit"); + assert!( + input.contains("years") && !input.contains("\\\"age\\\""), + "{input}" + ); + for edit in &edits { + assert!(matches!(edit, EngineEdit::ReplaceNode { .. })); + } +} + +#[test] +fn undeclared_input_rename_rewrites_every_reader() { + use zen_engine::policy::RenameTarget; + + let mut ws = Workspace::new(); + ws.set_document( + "g", + document(linear_graph( + None, + vec![expression_node( + "calc", + &[("total", "age * 2"), ("adult", "age >= 18")], + )], + )), + ); + let cursor = Cursor { + policy_path: "g".into(), + block_id: "calc".into(), + pos: 1, + target: CursorTarget::Expression { + id: "calc-e1".into(), + }, + }; + let prepared = ws.prepare_rename(&cursor).expect("prepare rename"); + assert!( + matches!(&prepared.target, RenameTarget::GraphProperty { path, .. } if path.as_ref() == "age") + ); + assert_eq!(ws.references(&prepared.target).len(), 2); + let edits = ws.rename(&prepared.target, "years"); + let rendered = serde_json::to_string(&edits).unwrap(); + assert!( + rendered.contains("years * 2") && rendered.contains("years >= 18"), + "{rendered}" + ); } #[test] @@ -1956,37 +2015,6 @@ fn graph_malformed_output_type_diagnosed() { ); } -#[test] -fn graph_nl_tokenize_output_cell_uses_declared_type() { - let mut ws = Workspace::new(); - ws.set_document( - "g", - document(linear_graph( - Some(person_schema()), - vec![typed_table("number", &["10"])], - )), - ); - let result = ws - .nl_tokenize( - &Cursor { - policy_path: "g".into(), - block_id: "dt".into(), - pos: 0, - target: CursorTarget::DecisionTableCell { - row: "r0".into(), - col: "o1".into(), - }, - }, - "10", - ) - .expect("tokenized"); - assert!( - matches!(result.subject_type, Some(VariableType::Number)), - "{:?}", - result.subject_type - ); -} - #[test] fn graph_untyped_output_column_keeps_inferred_behavior() { let mut ws = Workspace::new(); @@ -1998,18 +2026,6 @@ fn graph_untyped_output_column_keeps_inferred_behavior() { )), ); assert!(ws.diagnostics("g").is_empty(), "{:?}", ws.diagnostics("g")); - let results = ws.nl("g"); - let cell = results - .iter() - .find(|e| { - matches!( - &e.target, - CursorTarget::DecisionTableCell { row, col } - if row.as_ref() == "r0" && col.as_ref() == "o1" - ) - }) - .expect("cell projection"); - assert!(cell.result.subject_options.is_none()); } #[test] @@ -3348,6 +3364,209 @@ fn missing_member_through_local_names_the_member_not_the_alias() { ); } +#[test] +fn member_writes_do_not_define_declared_input_objects() { + let schema = json!({ + "type": "object", + "properties": { + "applicant": { + "type": "object", + "properties": { "age": { "type": "number" } }, + "required": ["age"] + } + }, + "required": ["applicant"] + }); + let mut ws = Workspace::new(); + ws.set_document( + "g", + document(linear_graph( + Some(schema), + vec![expression_node( + "calc", + &[ + ("applicant.adult", "applicant.age >= 18"), + ("result.band", "'A'"), + ], + )], + )), + ); + + let writer = |path: &str| { + ws.dependencies_scoped(path, Some("g")) + .written_by + .map(|block| block.block_id.to_string()) + }; + assert_eq!(writer("applicant"), None); + assert_eq!(writer("applicant.age"), None); + assert_eq!(writer("applicant.adult").as_deref(), Some("calc")); + assert_eq!(writer("result").as_deref(), Some("calc")); +} + +#[test] +fn expression_key_segments_resolve_in_graph_rows() { + use zen_engine::policy::RenameTarget; + + let mut ws = Workspace::new(); + ws.set_document( + "g", + document(linear_graph( + Some(person_schema()), + vec![ + expression_node("calc", &[("result.adult", "age >= 18")]), + expression_node("use", &[("flag", "result.adult")]), + ], + )), + ); + let key_cursor = |pos: u32| Cursor { + policy_path: "g".into(), + block_id: "calc".into(), + pos, + target: CursorTarget::ExpressionKey { + id: Some("calc-e0".into()), + }, + }; + + let prepared = ws.prepare_rename(&key_cursor(8)).expect("member segment"); + let RenameTarget::GraphProperty { path, .. } = &prepared.target else { + panic!("unexpected target {:?}", prepared.target); + }; + assert_eq!(path.as_ref(), "result.adult"); + assert_eq!(prepared.span, (7, 12)); + + let root = ws.prepare_rename(&key_cursor(2)).expect("root segment"); + let RenameTarget::GraphProperty { path, .. } = &root.target else { + panic!("unexpected target {:?}", root.target); + }; + assert_eq!(path.as_ref(), "result"); + + assert!(ws.inspect(&key_cursor(8)).is_some()); +} + +#[test] +fn date_input_of_child_decision_accepts_date_values_and_date_strings() { + let mut ws = Workspace::new(); + let date_schema = |key: &str, format: &str| { + json!({ + "type": "object", + "properties": { key: { "type": "string", "format": format } }, + "required": [key] + }) + }; + ws.set_document( + "child", + document(linear_graph( + Some(date_schema("when", "date-time")), + vec![expression_node("calc", &[("year", "d(when).year()")])], + )), + ); + let call = || node("call", "decisionNode", json!({ "key": "child" })); + ws.set_document( + "from_date_value", + document(linear_graph( + Some(date_schema("start", "date")), + vec![expression_node("prep", &[("when", "d(start)")]), call()], + )), + ); + ws.set_document( + "from_date_string", + document(linear_graph( + Some(date_schema("when", "date-time")), + vec![call()], + )), + ); + + for parent in ["from_date_value", "from_date_string"] { + let errors: Vec<_> = ws + .diagnostics(parent) + .into_iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "{parent}: {errors:#?}"); + } + + let when = ws + .inputs(&ScopeRequest::for_policy("child")) + .into_iter() + .find(|p| p.path.as_ref() == "when") + .expect("child input 'when'"); + let (inner, _) = when.resolved_type.unwrap_nullable(); + assert!( + matches!(inner, VariableType::Date), + "got {}", + when.resolved_type + ); +} + +#[test] +fn graph_renames_handle_astral_characters() { + use zen_engine::policy::{EngineEdit, RenameTarget}; + + let replaced = |edits: &[EngineEdit], id: &str| -> Value { + edits + .iter() + .find_map(|edit| match edit { + EngineEdit::ReplaceNode { + node_id, new_node, .. + } if node_id.as_ref() == id => Some(new_node.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("node {id} rewritten: {edits:?}")) + }; + + let mut ws = Workspace::new(); + let function = node( + "fn", + "functionNode", + json!({ "source": "export const handler = async (input) => {\n const tag = '🚀 fast';\n return { score: input.age * 2 };\n};\n" }), + ); + ws.set_document( + "doc", + document(linear_graph(Some(person_schema()), vec![function])), + ); + let edits = ws.rename( + &RenameTarget::GraphProperty { + document: Arc::from("doc"), + path: Arc::from("age"), + }, + "years", + ); + let source = replaced(&edits, "fn")["content"]["source"] + .as_str() + .unwrap() + .to_string(); + assert!(source.contains("input.years * 2"), "{source}"); + assert!(source.contains("'🚀 fast'"), "{source}"); + + let mut ws = Workspace::new(); + let mut calc = expression_node("calc", &[("total", "age * 2")]); + calc["content"]["passThrough"] = json!(true); + let mut label = expression_node( + "label", + &[( + "text", + "'🚀' + string($nodes['calc'].total) + '🚀' + string($nodes.calc.total)", + )], + ); + label["content"]["passThrough"] = json!(true); + ws.set_document( + "doc", + document(linear_graph(Some(person_schema()), vec![calc, label])), + ); + let edits = ws.rename( + &RenameTarget::GraphNode { + document: Arc::from("doc"), + node_id: Arc::from("calc"), + }, + "Calc2", + ); + let rows = replaced(&edits, "label")["content"]["expressions"].clone(); + assert_eq!( + rows[0]["value"], + json!("'🚀' + string($nodes['Calc2'].total) + '🚀' + string($nodes.Calc2.total)") + ); +} + #[test] fn field_rename_reaches_imported_policies_reading_it() { use zen_engine::policy::{ReferenceKind, RenameTarget}; @@ -3400,3 +3619,151 @@ fn field_rename_reaches_imported_policies_reading_it() { "{sites:#?}" ); } + +#[test] +fn input_rename_reaches_nodes_reads_and_child_decisions() { + use zen_engine::policy::{EngineEdit, RenameTarget}; + + let schema = json!({ + "type": "object", + "properties": { + "customer": { + "type": "object", + "properties": { "age": { "type": "number" }, "name": { "type": "string" } }, + "required": ["age", "name"] + } + }, + "required": ["customer"] + }); + let mut ws = Workspace::new(); + ws.set_document( + "child", + document(linear_graph( + Some(schema.clone()), + vec![expression_node( + "verdict", + &[("adult", "customer.age >= 18")], + )], + )), + ); + let mut graph = linear_graph( + Some(schema), + vec![ + expression_node("calc", &[("isAdult", "customer.age >= 18")]), + expression_node("calc2", &[("next", "$nodes.in.customer.age + 1")]), + node("call", "decisionNode", json!({ "key": "child" })), + ], + ); + graph["nodes"][1]["content"]["passThrough"] = json!(true); + graph["nodes"][2]["content"]["passThrough"] = json!(true); + ws.set_document("parent", document(graph)); + + let edits = ws.rename( + &RenameTarget::GraphProperty { + document: Arc::from("parent"), + path: Arc::from("customer.age"), + }, + "years", + ); + let rendered: Vec = edits + .iter() + .map(|edit| match edit { + EngineEdit::ReplaceNode { + document, + node_id, + new_node, + } => format!("{document}/{node_id}: {new_node}"), + other => format!("{other:?}"), + }) + .collect(); + let find = |doc: &str, id: &str| { + rendered + .iter() + .find(|r| r.starts_with(&format!("{doc}/{id}:"))) + .unwrap_or_else(|| panic!("no edit for {doc}/{id}: {rendered:#?}")) + }; + assert!(find("parent", "calc").contains("customer.years >= 18")); + assert!(find("parent", "calc2").contains("$nodes.in.customer.years + 1")); + assert!(find("parent", "in").contains("years")); + assert!(find("child", "verdict").contains("customer.years >= 18")); + assert!(find("child", "in").contains("years")); +} + +#[test] +fn output_column_head_rename_renames_written_property() { + use zen_engine::policy::{EngineEdit, RenameTarget}; + + let schema = json!({ + "type": "object", + "properties": { + "applicant": { + "type": "object", + "properties": { "score": { "type": "number" } }, + "required": ["score"] + } + }, + "required": ["applicant"] + }); + let mut ws = Workspace::new(); + let mut table = node( + "dt", + "decisionTableNode", + json!({ + "hitPolicy": "first", + "inputField": "applicant", + "inputs": [{ "id": "c1", "name": "Score", "field": "score" }], + "outputs": [{ "id": "o1", "name": "Points", "field": "points" }], + "rules": [{ "_id": "r1", "c1": "> 10", "o1": "1" }, { "_id": "r2", "c1": "", "o1": "0" }] + }), + ); + table["content"]["passThrough"] = json!(true); + let after = expression_node( + "after", + &[("double", "points * 2"), ("raw", "applicant.score")], + ); + ws.set_document( + "g", + document(linear_graph(Some(schema), vec![table, after])), + ); + + let prepared = ws + .prepare_rename(&Cursor { + policy_path: Arc::from("g"), + block_id: Arc::from("dt"), + target: CursorTarget::DecisionTableHead { + col: Arc::from("o1"), + }, + pos: 2, + }) + .expect("output head is renamable"); + assert_eq!( + prepared.target, + RenameTarget::GraphProperty { + document: Arc::from("g"), + path: Arc::from("points"), + } + ); + + let edits = ws.rename(&prepared.target, "rating"); + let node_json = |id: &str| { + edits + .iter() + .find_map(|edit| match edit { + EngineEdit::ReplaceNode { + node_id, new_node, .. + } if node_id.as_ref() == id => Some(new_node.to_string()), + _ => None, + }) + .unwrap_or_else(|| panic!("no edit for {id}: {edits:?}")) + }; + let dt = node_json("dt"); + assert!(dt.contains(r#""field":"rating""#), "{dt}"); + assert!( + dt.contains(r#""field":"score""#), + "input column untouched: {dt}" + ); + assert!(!dt.contains("points"), "{dt}"); + let after = node_json("after"); + assert!(after.contains("rating * 2"), "{after}"); + assert!(after.contains("applicant.score"), "{after}"); +} diff --git a/core/engine/tests/workspace_graph_dictionary.rs b/core/engine/tests/workspace_graph_dictionary.rs index 3de86c00..ea2c49d8 100644 --- a/core/engine/tests/workspace_graph_dictionary.rs +++ b/core/engine/tests/workspace_graph_dictionary.rs @@ -2,8 +2,7 @@ use std::sync::Arc; use serde_json::{json, Value}; use zen_engine::model::DecisionContent; -use zen_engine::policy::{Cursor, CursorTarget, DiagnosticCode, ScopeRequest, Severity, Workspace}; -use zen_expression::nl::{EditHint, NlTokenKind}; +use zen_engine::policy::{DiagnosticCode, ScopeRequest, Severity, Workspace}; fn document(value: Value) -> DecisionContent { serde_json::from_value(value).expect("valid decision content") @@ -250,42 +249,6 @@ fn dictionary_edit_invalidates_graph_analysis() { ); } -#[test] -fn nl_tokenize_graph_cell_uses_dictionary_labels() { - let mut ws = Workspace::new(); - ws.set_document( - "dicts", - document(dictionary_policy( - &[], - "customerTier", - &[("VIP", "Very important"), ("STD", "Standard")], - )), - ); - ws.set_document("g", document(graph(&["dicts"], tier_table("'VIP'")))); - - let cursor = Cursor { - policy_path: "g".into(), - block_id: "dt".into(), - pos: 0, - target: CursorTarget::DecisionTableCell { - row: "r1".into(), - col: "o1".into(), - }, - }; - let result = ws.nl_tokenize(&cursor, "'VIP'").expect("cursor resolves"); - let str_tok = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Str { .. })) - .expect("string token present"); - let Some(EditHint::Select { options }) = str_tok.hint else { - panic!("expected select hint, got {:?}", str_tok.hint); - }; - let options = &result.enums[options as usize]; - let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect(); - assert_eq!(labels, vec!["Very important", "Standard"]); -} - fn schema_graph(imports: &[&str], tier_schema: Value, output_schema: Option) -> Value { let schema = json!({ "type": "object", diff --git a/core/expression/README.md b/core/expression/README.md index 2b23a000..9dfd7c31 100644 --- a/core/expression/README.md +++ b/core/expression/README.md @@ -5,7 +5,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![crates.io](https://img.shields.io/crates/v/zen-expression.svg)](https://crates.io/crates/zen-expression) -ZEN expressions are readable by business users and fast enough for hot paths: a complete language for conditions, calculations and data transformation, with lexer, parser, compiler, VM, type checking and natural-language rendering included in this crate. +ZEN expressions are readable by business users and fast enough for hot paths: a complete language for conditions, calculations and data transformation, with lexer, parser, compiler, VM, type checking and editor completions included in this crate. ```rust use serde_json::json; diff --git a/core/expression/src/functions/defs.rs b/core/expression/src/functions/defs.rs index 2e7ef090..f1696760 100644 --- a/core/expression/src/functions/defs.rs +++ b/core/expression/src/functions/defs.rs @@ -197,6 +197,7 @@ impl FunctionDefinition for CompositeFunction { .collect::>() .join("\n"); typecheck.general = Some(format!("No function overload matches provided arguments. Available overloads:\n{available_signatures}")); + typecheck.return_type = self.return_type(); typecheck } @@ -225,10 +226,10 @@ impl FunctionDefinition for CompositeFunction { } let is_optional = possible_types.len() != self.signatures.len(); + let mut seen = HashSet::new(); let possible_types: Vec = possible_types .into_iter() - .collect::>() - .into_iter() + .filter(|t| seen.insert(t.clone())) .collect(); let type_union = possible_types.join(" | "); @@ -259,10 +260,10 @@ impl FunctionDefinition for CompositeFunction { return String::from("never"); } + let mut seen = HashSet::new(); possible_types .into_iter() - .collect::>() - .into_iter() + .filter(|t| seen.insert(t.clone())) .collect::>() .join(" | ") } diff --git a/core/expression/src/intellisense/completion.rs b/core/expression/src/intellisense/completion.rs index 9fd68bb3..c0d88f73 100644 --- a/core/expression/src/intellisense/completion.rs +++ b/core/expression/src/intellisense/completion.rs @@ -4,8 +4,11 @@ use crate::functions::{ ClosureFunction, DeprecatedFunction, FunctionKind, InternalFunction, MethodKind, MethodRegistry, }; use crate::intellisense::IntelliSenseToken; +use crate::lexer::codes::is_token_type; use crate::variable::VariableType; +use ahash::HashMap; use serde::Serialize; +use std::rc::Rc; use strum::IntoEnumIterator; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -28,16 +31,72 @@ pub struct Completion { pub boost: Option, #[serde(skip_serializing_if = "Option::is_none")] pub method_for: Option, + #[serde(skip)] + pub var_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub follow: Option<&'static str>, } pub struct Completions; impl Completions { + pub fn from_slot( + source: &str, + pos: u32, + data: &VariableType, + slot: &crate::slot::Slot, + ) -> Vec { + use crate::slot::SlotState; + if slot.suppress_completions + || matches!( + slot.state, + SlotState::Operator | SlotState::Logical | SlotState::InString + ) + { + return Vec::new(); + } + let locals = slot + .locals + .iter() + .map(|local| (Rc::from(local.name.as_str()), local.kind.shallow_clone())) + .collect::>(); + let mut items = match slot.state { + SlotState::Member => { + Self::build_property(slot.operand.as_ref().unwrap_or(&VariableType::Any)) + } + SlotState::Path if slot.operand.is_some() => { + let mut items = Self::build_property(slot.operand.as_ref().unwrap()); + items.retain(|item| item.kind != CompletionKind::Method); + items + } + SlotState::Path => { + let mut items = Self::build_scope(data, &locals); + items.retain(|item| { + item.kind != CompletionKind::Function && !item.label.starts_with('$') + }); + items + } + _ => Self::build_scope(data, &locals), + }; + for item in &mut items { + if let Some(t) = &item.var_type { + item.follow = match t.unwrap_nullable().0 { + VariableType::Object(_) => Some("."), + _ if slot.can_chain => Some(" "), + _ => None, + }; + } + } + let before = source.get(..pos as usize).unwrap_or(source); + Self::filter(items, Self::extract_prefix(before)) + } + pub fn build( source: &str, pos: u32, data: &VariableType, tokens: &[IntelliSenseToken], + locals: &[(Rc, VariableType)], ) -> Vec { let before = source.get(..pos as usize).unwrap_or(source); let prefix = Self::extract_prefix(before); @@ -58,7 +117,7 @@ impl Completions { Self::build_property(&target_type) } - None => Self::build_scope(data), + None => Self::build_scope(data, locals), }; Self::filter(completions, prefix) @@ -72,8 +131,7 @@ impl Completions { }; if let VariableType::Object(obj) = resolved { - let obj = obj.borrow(); - for (key, val) in obj.iter() { + for (key, val) in Self::sorted_fields(&obj.borrow()) { completions.push(Completion { label: key.to_string(), kind: CompletionKind::Property, @@ -81,6 +139,8 @@ impl Completions { info: String::new(), boost: Some(10), method_for: None, + var_type: Some(val.shallow_clone()), + follow: None, }); } } @@ -90,7 +150,12 @@ impl Completions { let applies = def .as_ref() .and_then(|d| d.param_type(0)) - .map(|pt| vt.satisfies(&pt)) + .map(|pt| match pt { + VariableType::Date => { + matches!(resolved, VariableType::Date | VariableType::Any) + } + _ => vt.satisfies(&pt), + }) .unwrap_or(false); if applies || matches!(vt, VariableType::Any) { @@ -101,17 +166,29 @@ impl Completions { completions } - pub fn build_scope(data: &VariableType) -> Vec { + pub fn build_scope(data: &VariableType, locals: &[(Rc, VariableType)]) -> Vec { let mut completions = Vec::new(); + for (name, kind) in locals { + completions.push(Completion { + label: name.to_string(), + kind: CompletionKind::Variable, + detail: kind.to_string(), + info: String::new(), + boost: Some(30), + method_for: None, + var_type: Some(kind.shallow_clone()), + follow: None, + }); + } + let resolved_data = match data { VariableType::Nullable(inner) => inner.as_ref(), other => other, }; if let VariableType::Object(obj) = resolved_data { - let obj = obj.borrow(); - for (key, val) in obj.iter() { + for (key, val) in Self::sorted_fields(&obj.borrow()) { completions.push(Completion { label: key.to_string(), kind: CompletionKind::Variable, @@ -119,6 +196,8 @@ impl Completions { info: String::new(), boost: Some(20), method_for: None, + var_type: Some(val.shallow_clone()), + follow: None, }); } } @@ -130,6 +209,8 @@ impl Completions { info: String::new(), boost: Some(-10), method_for: None, + var_type: None, + follow: None, }); completions.extend( @@ -142,6 +223,24 @@ impl Completions { completions } + fn sorted_fields(fields: &HashMap, VariableType>) -> Vec<(&Rc, &VariableType)> { + let mut out: Vec<_> = fields.iter().collect(); + out.sort_by_cached_key(|(key, _)| { + (key.starts_with('$'), key.to_lowercase(), key.to_string()) + }); + out + } + + pub(crate) fn function_named(name: &str) -> Option { + FunctionKind::try_from(name) + .ok() + .map(|fk| Self::function(fk, None)) + } + + pub(crate) fn method_named(name: &str) -> Option { + MethodKind::try_from(name).ok().map(Self::method) + } + fn function(fk: FunctionKind, boost_override: Option) -> Completion { let label = fk.to_string(); let info = function_info(&fk); @@ -159,6 +258,8 @@ impl Completions { info, boost, method_for: None, + var_type: None, + follow: None, } } @@ -174,13 +275,17 @@ impl Completions { info, boost: None, method_for, + var_type: None, + follow: None, } } fn extract_prefix(before_cursor: &str) -> &str { let boundary = before_cursor - .rfind(|c: char| !c.is_alphanumeric() && c != '_' && c != '$' && c != '#') - .map(|i| i + 1) + .char_indices() + .rev() + .find(|(_, c)| !is_token_type!(*c, "alphanumeric")) + .map(|(i, c)| i + c.len_utf8()) .unwrap_or(0); &before_cursor[boundary..] @@ -204,7 +309,7 @@ impl Completions { return Some(trimmed.len() - 1); } - let word_start = trimmed.rfind(|c: char| !c.is_alphanumeric() && c != '_' && c != '#'); + let word_start = trimmed.rfind(|c: char| !is_token_type!(c, "alphanumeric")); match word_start { Some(i) if trimmed.as_bytes().get(i) == Some(&b'.') => Some(i), _ => None, @@ -455,7 +560,7 @@ fn method_param_names(mk: &MethodKind) -> Vec<&'static str> { match mk { MethodKind::DateMethod(dm) => match dm { DateMethod::Add | DateMethod::Sub => vec!["amount", "unit"], - DateMethod::Set => vec!["value", "unit"], + DateMethod::Set => vec!["unit", "value"], DateMethod::Format => vec!["format"], DateMethod::StartOf | DateMethod::EndOf => vec!["unit"], DateMethod::Diff => vec!["otherDate", "unit"], diff --git a/core/expression/src/intellisense/dependency.rs b/core/expression/src/intellisense/dependency.rs index 149c3b23..c23d938a 100644 --- a/core/expression/src/intellisense/dependency.rs +++ b/core/expression/src/intellisense/dependency.rs @@ -537,28 +537,44 @@ impl<'a> DependencyResolutionWalker<'a> { cumulative.push(name.clone()); } ChainSegment::Dynamic { prop } => { - if !group_path.is_empty() { - self.references.push(Reference { - path: std::mem::take(&mut group_path), - spans: std::mem::take(&mut group_spans), - via_alias: None, - via_index: Some(via_index_for_group.clone()), - }); - } + self.emit_pointer_group( + std::mem::take(&mut group_path), + std::mem::take(&mut group_spans), + via_index_for_group.clone(), + ); self.resolve(prop, scope); via_index_for_group = cumulative.clone(); } } } - if !group_path.is_empty() { - self.references.push(Reference { - path: group_path, - spans: group_spans, - via_alias: None, - via_index: Some(via_index_for_group), - }); + self.emit_pointer_group(group_path, group_spans, via_index_for_group); + } + + fn emit_pointer_group( + &mut self, + path: Vec>, + spans: Vec<(u32, u32)>, + collection: Vec>, + ) { + if path.is_empty() { + return; } + let span = match (spans.first(), spans.last()) { + (Some(first), Some(last)) => (first.0, last.1), + _ => Default::default(), + }; + self.reads.push(ReadDependency::Direct { + path: collection.iter().chain(path.iter()).cloned().collect(), + span, + via_index: true, + }); + self.references.push(Reference { + path, + spans, + via_alias: None, + via_index: Some(collection), + }); } fn emit_group( diff --git a/core/expression/src/intellisense/diagnostic.rs b/core/expression/src/intellisense/diagnostic.rs index 59cb2be7..3964154d 100644 --- a/core/expression/src/intellisense/diagnostic.rs +++ b/core/expression/src/intellisense/diagnostic.rs @@ -1,14 +1,17 @@ use crate::compiler::CompilerError; use crate::intellisense::type_provider::TypesProvider; -use crate::lexer::LexerError; +use crate::lexer::{LexerError, Operator}; +use crate::parser::AstNodeError; use crate::parser::Node; use nohash_hasher::BuildNoHashHasher; use serde::Serialize; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use crate::parser::NodeMetadata; +pub type DiagnosticArgs = BTreeMap<&'static str, String>; + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Diagnostic { @@ -16,6 +19,45 @@ pub struct Diagnostic { pub message: String, pub severity: Severity, pub source: DiagnosticSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option<&'static str>, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub args: DiagnosticArgs, +} + +impl Diagnostic { + pub fn new( + span: (u32, u32), + message: String, + severity: Severity, + source: DiagnosticSource, + ) -> Self { + Self { + span, + message, + severity, + source, + code: None, + args: DiagnosticArgs::new(), + } + } + + pub fn incomplete() -> Self { + let mut diagnostic = Self::new( + (0, 0), + "Incomplete expression".to_string(), + Severity::Error, + DiagnosticSource::Parser, + ); + diagnostic.code = Some("expr.two-operands"); + diagnostic + } + + fn with_code(mut self, code: &'static str, args: &[(&'static str, String)]) -> Self { + self.code = Some(code); + self.args = args.iter().cloned().collect(); + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -42,27 +84,158 @@ pub(crate) fn lexer_error_to_diagnostic(err: &LexerError) -> Diagnostic { LexerError::UnexpectedEof { position, .. } => (*position, *position), }; - Diagnostic { + let (code, args): (&'static str, Vec<(&'static str, String)>) = match err { + LexerError::UnexpectedEof { .. } => ("expr.unterminated-string", Vec::new()), + LexerError::UnexpectedSymbol { symbol, .. } => { + ("expr.unexpected-token", vec![("token", symbol.clone())]) + } + LexerError::UnmatchedSymbol { symbol, .. } => { + ("expr.unexpected-token", vec![("token", symbol.to_string())]) + } + }; + + Diagnostic::new( span, - message: err.to_string(), - severity: Severity::Error, - source: DiagnosticSource::Lexer, + err.to_string(), + Severity::Error, + DiagnosticSource::Lexer, + ) + .with_code(code, &args) +} + +fn parser_error_code( + error: &AstNodeError, + missing_after: Option, +) -> (&'static str, Vec<(&'static str, String)>) { + if let Some(operator) = missing_after { + return ( + "expr.missing-value", + vec![("operator", operator.to_string())], + ); + } + + match error { + AstNodeError::UnknownBuiltIn { name, .. } => { + ("expr.unknown-function", vec![("name", name.to_string())]) + } + AstNodeError::UnknownMethod { name, .. } => { + ("expr.unknown-method", vec![("name", name.to_string())]) + } + AstNodeError::UnexpectedIdentifier { received, .. } + | AstNodeError::UnexpectedToken { received, .. } => ( + "expr.unexpected-token", + vec![("token", received.to_string())], + ), + AstNodeError::InvalidNumber { number, .. } => { + ("expr.invalid-number", vec![("number", number.to_string())]) + } + AstNodeError::InvalidBoolean { boolean, .. } => ( + "expr.unexpected-token", + vec![("token", boolean.to_string())], + ), + AstNodeError::InvalidProperty { .. } | AstNodeError::ExpectedProperty { .. } => { + ("expr.expected-property", Vec::new()) + } + AstNodeError::MissingToken { .. } + | AstNodeError::ExpectedLiteral { .. } + | AstNodeError::UnexpectedEnd { .. } => ("expr.unexpected-end", Vec::new()), + AstNodeError::Custom { .. } => ("expr.syntax", Vec::new()), } } pub(crate) fn collect_parser_diagnostics(ast: &Node, diagnostics: &mut Vec) { - let collected = RefCell::new(Vec::new()); - ast.walk(|node| { - if let Node::Error { error, .. } = node { - collected.borrow_mut().push(Diagnostic { - span: node.span().unwrap_or_default(), - message: error.to_string(), - severity: Severity::Error, - source: DiagnosticSource::Parser, - }); + let mut stack: Vec<(&Node, Option)> = vec![(ast, None)]; + while let Some((node, missing_after)) = stack.pop() { + match node { + Node::Error { error, node: inner } => { + let (code, args) = parser_error_code(error, missing_after); + diagnostics.push( + Diagnostic::new( + node.span().unwrap_or_default(), + error.to_string(), + Severity::Error, + DiagnosticSource::Parser, + ) + .with_code(code, &args), + ); + if let Some(inner) = inner { + stack.push((inner, None)); + } + } + Node::Binary { + left, + operator, + right, + } => { + let missing = matches!(right, Node::Error { node: None, .. }).then_some(*operator); + stack.push((right, missing)); + stack.push((left, None)); + } + Node::Unary { node, .. } + | Node::Closure { body: node, .. } + | Node::Parenthesized(node) => stack.push((node, None)), + Node::Member { node, property } => { + stack.push((property, None)); + stack.push((node, None)); + } + Node::Slice { node, from, to } => { + if let Some(to) = to { + stack.push((to, None)); + } + if let Some(from) = from { + stack.push((from, None)); + } + stack.push((node, None)); + } + Node::Interval { left, right, .. } => { + stack.push((right, None)); + stack.push((left, None)); + } + Node::Conditional { + condition, + on_true, + on_false, + } => { + stack.push((on_false, None)); + stack.push((on_true, None)); + stack.push((condition, None)); + } + Node::TemplateString(items) | Node::Array(items) => { + stack.extend(items.iter().rev().map(|n| (*n, None))); + } + Node::Object(pairs) => { + for (k, v) in pairs.iter().rev() { + stack.push((v, None)); + stack.push((k, None)); + } + } + Node::Assignments { list, output } => { + if let Some(output) = output { + stack.push((output, None)); + } + for (k, v) in list.iter().rev() { + stack.push((v, None)); + stack.push((k, None)); + } + } + Node::FunctionCall { arguments, .. } => { + stack.extend(arguments.iter().rev().map(|n| (*n, None))); + } + Node::MethodCall { + this, arguments, .. + } => { + stack.extend(arguments.iter().rev().map(|n| (*n, None))); + stack.push((this, None)); + } + Node::Null + | Node::Bool(_) + | Node::Number(_) + | Node::String(_) + | Node::Pointer + | Node::Identifier(_) + | Node::Root => {} } - }); - diagnostics.extend(collected.into_inner()); + } } pub(crate) fn collect_type_diagnostics( @@ -95,21 +268,21 @@ pub(crate) fn collect_type_diagnostics( (Severity::Error, error.clone()) }; - collected.borrow_mut().push(Diagnostic { - span, - message, - severity, - source: DiagnosticSource::TypeCheck, - }); + let mut diagnostic = Diagnostic::new(span, message, severity, DiagnosticSource::TypeCheck); + if let Some((code, args)) = type_data.code_of(node) { + diagnostic.code = Some(code); + diagnostic.args = args.clone(); + } + collected.borrow_mut().push(diagnostic); }); diagnostics.extend(collected.into_inner()); } pub(crate) fn compiler_error_to_diagnostic(err: &CompilerError) -> Diagnostic { - Diagnostic { - span: (0, 0), - message: err.to_string(), - severity: Severity::Error, - source: DiagnosticSource::Compiler, - } + Diagnostic::new( + (0, 0), + err.to_string(), + Severity::Error, + DiagnosticSource::Compiler, + ) } diff --git a/core/expression/src/intellisense/inspection.rs b/core/expression/src/intellisense/inspection.rs index 55e638f6..d00564ce 100644 --- a/core/expression/src/intellisense/inspection.rs +++ b/core/expression/src/intellisense/inspection.rs @@ -1,4 +1,5 @@ use crate::intellisense::IntelliSenseToken; +use crate::lexer::{Identifier, LenientTokens, QuotationMark, TemplateString, Token, TokenKind}; use crate::variable::VariableType; use serde::Serialize; @@ -8,26 +9,99 @@ pub struct InspectionResult { pub span: (u32, u32), pub kind: VariableType, pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option, } -pub(crate) fn inspect_at( - source: &str, - pos: u32, - tokens: &[IntelliSenseToken], -) -> Option { - let token = tokens - .iter() - .filter(|t| t.span.0 <= pos && pos <= t.span.1 && t.span.0 < t.span.1) - .min_by_key(|t| t.span.1 - t.span.0)?; +impl InspectionResult { + pub(crate) fn typed(source: &str, span: (u32, u32), kind: VariableType) -> Self { + Self { + span, + kind, + label: source + .get(span.0 as usize..span.1 as usize) + .unwrap_or_default() + .to_string(), + detail: None, + info: None, + } + } +} - let label = source - .get(token.span.0 as usize..token.span.1 as usize) - .unwrap_or("") - .to_string(); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HoverWord { + Name, + Call { member: bool }, +} - Some(InspectionResult { - span: token.span, - kind: token.kind.clone(), - label, - }) +pub(crate) struct Hover; + +impl Hover { + pub(crate) fn smallest_token( + source: &str, + pos: u32, + tokens: &[IntelliSenseToken], + ) -> Option { + let token = tokens + .iter() + .filter(|t| t.span.0 <= pos && pos <= t.span.1 && t.span.0 < t.span.1) + .min_by_key(|t| t.span.1 - t.span.0)?; + Some(InspectionResult::typed( + source, + token.span, + token.kind.clone(), + )) + } + + pub(crate) fn word_at( + source: &str, + pos: u32, + lenient: &LenientTokens, + ) -> Option<((u32, u32), HoverWord)> { + if lenient.open_string.is_some_and(|(_, start)| start < pos) { + return None; + } + let tokens = &lenient.tokens; + let index = tokens + .iter() + .position(|t| t.span.0 <= pos && pos <= t.span.1 && Self::is_name(t))?; + let token = &tokens[index]; + let previous = index.checked_sub(1).and_then(|i| tokens.get(i)); + if token.kind == TokenKind::Literal && previous.is_some_and(Self::opens_string_body) { + return None; + } + let called = source + .get(token.span.1 as usize..) + .is_some_and(|rest| rest.trim_start().starts_with('(')); + let member = previous.is_some_and(|t| t.value == "."); + let word = if called { + HoverWord::Call { member } + } else { + HoverWord::Name + }; + Some((token.span, word)) + } + + fn is_name(token: &Token) -> bool { + match token.kind { + TokenKind::Literal => token + .value + .chars() + .next() + .is_some_and(|c| !c.is_ascii_digit()), + TokenKind::Identifier(identifier) => identifier != Identifier::Null, + _ => false, + } + } + + fn opens_string_body(token: &Token) -> bool { + matches!( + token.kind, + TokenKind::QuotationMark( + QuotationMark::SingleQuote | QuotationMark::DoubleQuote | QuotationMark::Backtick + ) | TokenKind::TemplateString(TemplateString::ExpressionEnd) + ) + } } diff --git a/core/expression/src/intellisense/mod.rs b/core/expression/src/intellisense/mod.rs index d80d6783..0f6cba35 100644 --- a/core/expression/src/intellisense/mod.rs +++ b/core/expression/src/intellisense/mod.rs @@ -3,15 +3,14 @@ use crate::intellisense::completion::Completions; use crate::intellisense::dependency::DependencyResolutionWalker; use crate::intellisense::diagnostic::{ collect_parser_diagnostics, collect_type_diagnostics, compiler_error_to_diagnostic, - lexer_error_to_diagnostic, Diagnostic, DiagnosticSource, Severity, + lexer_error_to_diagnostic, Diagnostic, }; -use crate::intellisense::inspection::{inspect_at, InspectionResult}; +use crate::intellisense::inspection::{Hover, HoverWord, InspectionResult}; use crate::intellisense::scope::IntelliSenseScope; use crate::intellisense::type_provider::TypesProvider; use crate::lexer::Lexer; -use crate::nl::project::Projector; -use crate::nl::{NlRequest, NlResult}; use crate::parser::{Node, NodeMetadata, Parser}; +use crate::slot::{SlotRole, SlotState}; use crate::variable::VariableType; use bumpalo::Bump; use nohash_hasher::BuildNoHashHasher; @@ -26,7 +25,7 @@ pub mod diagnostic; mod discriminant; mod entity_flow; mod inspection; -mod scope; +pub(crate) mod scope; pub(crate) mod type_provider; pub use dependency::{DependencyResult, ReadDependency, Reference}; @@ -53,13 +52,13 @@ pub struct ExpressionAnalysis { pub diagnostics: Vec, } -pub type NlLabelResolver = Rc Option>; +pub use crate::slot::LabelResolver; pub struct IntelliSense { - arena: Bump, - lexer: Lexer, - strict: bool, - nl_labels: Option, + pub(crate) arena: Bump, + pub(crate) lexer: Lexer, + pub(crate) strict: bool, + pub(crate) labels: Option, } impl IntelliSense { @@ -68,7 +67,7 @@ impl IntelliSense { arena: Bump::new(), lexer: Lexer::new(), strict: false, - nl_labels: None, + labels: None, } } @@ -77,8 +76,8 @@ impl IntelliSense { self } - pub fn set_nl_labels(&mut self, labels: Option) { - self.nl_labels = labels; + pub fn set_labels(&mut self, labels: Option) { + self.labels = labels; } pub fn completions( @@ -87,22 +86,70 @@ impl IntelliSense { pos: u32, data: &VariableType, ) -> Vec { + let locals = self.closure_locals(source, pos, data); let tokens = match self.type_check(source, data) { Some(t) => t, - None => return Completions::build_scope(data), + None => return Completions::build_scope(data, &locals), }; - Completions::build(source, pos, data, &tokens) + Completions::build(source, pos, data, &tokens, &locals) } pub fn inspect( + &mut self, + source: &str, + pos: u32, + unary: bool, + role: SlotRole, + data: &VariableType, + ) -> Option { + self.arena.reset(); + let word = self + .lexer + .tokenize_lenient(&self.arena, source) + .ok() + .and_then(|lenient| Hover::word_at(source, pos, &lenient)); + match word { + Some((span, HoverWord::Call { member })) => { + let name = source.get(span.0 as usize..span.1 as usize)?; + let completion = if member { + Completions::method_named(name) + } else { + Completions::function_named(name) + }?; + let kind = self + .inspect_typed(source, pos, data) + .map_or(VariableType::Any, |call| call.kind); + Some(InspectionResult { + detail: Some(completion.detail), + info: Some(completion.info).filter(|info| !info.is_empty()), + ..InspectionResult::typed(source, span, kind) + }) + } + Some((span, HoverWord::Name)) => { + let probe = format!("{}.", source.get(..span.1 as usize)?); + let slot = self + .slot(&probe, probe.len() as u32, unary, role, data, None) + .slot; + match (slot.state, slot.operand) { + (SlotState::Member | SlotState::Path, Some(kind)) => { + Some(InspectionResult::typed(source, span, kind)) + } + _ => self.inspect_typed(source, pos, data), + } + } + None => self.inspect_typed(source, pos, data), + } + } + + fn inspect_typed( &mut self, source: &str, pos: u32, data: &VariableType, ) -> Option { let tokens = self.type_check(source, data)?; - inspect_at(source, pos, &tokens) + Hover::smallest_token(source, pos, &tokens) } pub fn analyze(&mut self, source: &str, data: &VariableType) -> Rc { @@ -142,12 +189,7 @@ impl IntelliSense { if !parser_result.is_complete || ast.has_error() { if !parser_result.is_complete { - diagnostics.push(Diagnostic { - span: (0, 0), - message: "Incomplete expression".to_string(), - severity: Severity::Error, - source: DiagnosticSource::Parser, - }); + diagnostics.push(Diagnostic::incomplete()); } collect_parser_diagnostics(ast, &mut diagnostics); return ExpressionAnalysis { @@ -191,139 +233,6 @@ impl IntelliSense { } } - pub fn nl_tokenize_batch( - &mut self, - requests: &[NlRequest], - root_type: &VariableType, - ) -> Vec { - requests - .iter() - .map(|request| self.nl_tokenize(request, root_type)) - .collect() - } - - pub fn nl_tokenize(&mut self, request: &NlRequest, root_type: &VariableType) -> NlResult { - let scope = if request.unary { - Self::unary_scope(root_type, request.subject_type.as_ref()) - } else { - root_type.shallow_clone() - }; - let expected = (!request.unary) - .then_some(request.subject_type.as_ref()) - .flatten(); - let mut result = self.nl_tokenize_scoped( - &request.id, - &request.expression, - request.unary, - &scope, - expected, - ); - if request.unary { - let subject = scope.get("$"); - result.subject_options = self.nl_subject_options(&subject); - result.subject_type = Some(subject); - } else if let Some(expected) = expected { - result.subject_options = self.nl_subject_options(expected); - result.subject_type = Some(expected.shallow_clone()); - } - result - } - - pub fn nl_subject_options(&self, subject: &VariableType) -> Option> { - crate::nl::subject_enum_options(subject, self.nl_labels.as_ref()) - } - - pub fn nl_tokenize_scoped( - &mut self, - id: &str, - source: &str, - unary: bool, - scope_type: &VariableType, - expected: Option<&VariableType>, - ) -> NlResult { - let mut result = NlResult { - id: id.to_string(), - tokens: Vec::new(), - enums: Vec::new(), - diagnostics: Vec::new(), - subject_type: None, - subject_options: None, - }; - - self.arena.reset(); - let arena = &self.arena; - - let tokens = match self.lexer.tokenize(arena, source) { - Ok(tokens) => tokens, - Err(err) => { - result.diagnostics.push(lexer_error_to_diagnostic(&err)); - return result; - } - }; - - let Ok(parser) = Parser::try_new(&tokens, arena) else { - return result; - }; - - let parser_result = if unary { - parser.unary().with_metadata().parse() - } else { - parser.standard().with_metadata().parse() - }; - let ast = parser_result.root; - - if !parser_result.is_complete || ast.has_error() { - if !parser_result.is_complete { - result.diagnostics.push(Diagnostic { - span: (0, 0), - message: "Incomplete expression".to_string(), - severity: Severity::Error, - source: DiagnosticSource::Parser, - }); - } - collect_parser_diagnostics(ast, &mut result.diagnostics); - return result; - } - - let metadata = parser_result.metadata.unwrap_or_default(); - - let scope = IntelliSenseScope { - pointer_data: scope_type.shallow_clone(), - root_data: scope_type.shallow_clone(), - current_data: scope_type.shallow_clone(), - ..Default::default() - }; - - let type_data = TypesProvider::generate(ast, scope, self.strict); - collect_type_diagnostics(ast, &type_data, &metadata, &mut result.diagnostics); - - let (tokens, enums) = - Projector::new(source, &type_data, &metadata, unary, self.nl_labels.clone()) - .run(ast, expected.map(|e| e.shallow_clone())); - result.tokens = tokens; - result.enums = enums; - result - } - - fn unary_scope(root_type: &VariableType, subject_type: Option<&VariableType>) -> VariableType { - let subject = subject_type - .map(|s| s.shallow_clone()) - .unwrap_or(VariableType::Any); - - let object = VariableType::empty_object(); - if let VariableType::Object(target) = &object { - if let VariableType::Object(source) = root_type { - for (key, value) in source.borrow().iter() { - target - .borrow_mut() - .insert(key.clone(), value.shallow_clone()); - } - } - target.borrow_mut().insert(Rc::from("$"), subject); - } - object - } - pub fn with_ast( &mut self, source: &str, @@ -506,12 +415,7 @@ impl IntelliSense { if !parser_result.is_complete || ast.has_error() { if !parser_result.is_complete { - diagnostics.push(Diagnostic { - span: (0, 0), - message: "Incomplete expression".to_string(), - severity: Severity::Error, - source: DiagnosticSource::Parser, - }); + diagnostics.push(Diagnostic::incomplete()); } collect_parser_diagnostics(ast, &mut diagnostics); return ExpressionAnalysis { diff --git a/core/expression/src/intellisense/type_provider.rs b/core/expression/src/intellisense/type_provider.rs index cb53e052..dfdb50d5 100644 --- a/core/expression/src/intellisense/type_provider.rs +++ b/core/expression/src/intellisense/type_provider.rs @@ -1,6 +1,7 @@ use crate::functions::internal::InternalFunction; use crate::functions::registry::FunctionRegistry; use crate::functions::{ClosureFunction, FunctionKind, MethodRegistry}; +use crate::intellisense::diagnostic::DiagnosticArgs; use crate::intellisense::scope::IntelliSenseScope; use crate::lexer::{ArithmeticOperator, ComparisonOperator, LogicalOperator, Operator}; use crate::parser::Node; @@ -62,13 +63,17 @@ impl From> for TypeInfo { #[derive(Debug)] pub struct TypesProvider { types: HashMap, + codes: HashMap, strict: bool, } +pub(crate) type ErrorCode = (&'static str, DiagnosticArgs); + impl TypesProvider { pub fn generate(root: &Node, scope: IntelliSenseScope, strict: bool) -> Self { let mut s = Self { types: HashMap::new(), + codes: HashMap::new(), strict, }; @@ -96,12 +101,40 @@ impl TypesProvider { } } + pub(crate) fn code_of(&self, node: &Node) -> Option<&ErrorCode> { + self.codes.get(&node_address(node)) + } + fn set_error(&mut self, node: &Node, message: String) { + self.codes.remove(&node_address(node)); self.update_type(node, |typ| { typ.error = Some(message); }); } + fn set_coded_error(&mut self, node: &Node, message: String, code: ErrorCode) { + self.set_error(node, message); + self.set_code(node, code); + } + + fn set_code(&mut self, node: &Node, code: ErrorCode) { + self.codes.insert(node_address(node), code); + } + + fn coded(&mut self, node: &Node, (message, code): (String, Option)) -> String { + if let Some(code) = code { + self.set_code(node, code); + } + message + } + + fn mismatch(expected: String, got: &VariableType) -> ErrorCode { + ( + "type.mismatch", + DiagnosticArgs::from([("expected", expected), ("got", got.to_string())]), + ) + } + fn maybe_nullable(kind: VariableType, nullable: bool) -> TypeInfo { if nullable && !matches!( @@ -281,9 +314,16 @@ impl TypesProvider { Some(t) => t.clone(), None if !self.strict || obj.is_empty() => VariableType::Any, None => { - self.set_error( + self.set_coded_error( node, format!("'{key}' is not a valid member of `{node_type}`."), + ( + "type.unknown-member", + DiagnosticArgs::from([ + ("name", key.to_string()), + ("of", node_type.to_string()), + ]), + ), ); VariableType::Any } @@ -361,7 +401,7 @@ impl TypesProvider { Operator::Comparison(comp) => match comp { ComparisonOperator::Equal => { match check_enum_comparison(&left_type, &right_type) { - Some(Some(err)) => { on_fly_error.replace(err); } + Some(Some(error)) => { on_fly_error.replace(self.coded(node, error)); } Some(None) => {} None => { let always_false = Self::structured_comparison(&left_type, &right_type) @@ -378,7 +418,7 @@ impl TypesProvider { }, ComparisonOperator::NotEqual => { match check_enum_comparison(&left_type, &right_type) { - Some(Some(err)) => { on_fly_error.replace(err); } + Some(Some(error)) => { on_fly_error.replace(self.coded(node, error)); } Some(None) => {} None => { let always_true = Self::structured_comparison(&left_type, &right_type) @@ -406,7 +446,7 @@ impl TypesProvider { ComparisonOperator::In | ComparisonOperator::NotIn => match (left_type.widen(), right_type.widen()) { (_, VariableType::Array(inner_type)) => { match check_enum_comparison(&left_type, &inner_type) { - Some(Some(err)) => { on_fly_error.replace(err); } + Some(Some(error)) => { on_fly_error.replace(self.coded(node, error)); } Some(None) => {} None => { if types_disjoint(&left_type, &inner_type) { @@ -577,7 +617,8 @@ impl TypesProvider { let typecheck = def.check_types(type_list.as_slice()); for (i, arg_error) in typecheck.arguments { - self.set_error(arguments[i], arg_error); + let code = Self::mismatch(def.param_type_str(i), &type_list[i]); + self.set_coded_error(arguments[i], arg_error, code); } TypeInfo { @@ -663,10 +704,11 @@ impl TypesProvider { let typecheck = def.check_types(type_list.as_slice()); for (i, arg_error) in typecheck.arguments { + let code = Self::mismatch(def.param_type_str(i), &type_list[i]); if i == 0 { - self.set_error(this, arg_error); + self.set_coded_error(this, arg_error, code); } else { - self.set_error(arguments[i - 1], arg_error); + self.set_coded_error(arguments[i - 1], arg_error, code); } } @@ -718,7 +760,10 @@ fn value_set(t: &VariableType) -> Option>> { } } -fn check_enum_comparison(left: &VariableType, right: &VariableType) -> Option> { +fn check_enum_comparison( + left: &VariableType, + right: &VariableType, +) -> Option)>> { let (left, _) = left.unwrap_nullable(); let (right, _) = right.unwrap_nullable(); @@ -732,8 +777,15 @@ fn check_enum_comparison(left: &VariableType, right: &VariableType) -> Option left, _ => right, }; - Some(Some(format!( - "Value `\"{c}\"` is not a valid member of `{enum_type}`." + Some(Some(( + format!("Value `\"{c}\"` is not a valid member of `{enum_type}`."), + Some(( + "type.invalid-enum-member", + DiagnosticArgs::from([ + ("value", c.to_string()), + ("enum", enum_type.to_string()), + ]), + )), ))) } } @@ -741,8 +793,9 @@ fn check_enum_comparison(left: &VariableType, right: &VariableType) -> Option { + pub tokens: BumpVec<'arena, Token<'arena>>, + pub open_string: Option<(QuotationMark, u32)>, +} + impl Lexer { pub fn new() -> Self { Self::default() @@ -30,12 +36,32 @@ impl Lexer { self.capacity_hint = self.capacity_hint.max(tokens.len()); Ok(tokens) } + + pub fn tokenize_lenient<'arena>( + &mut self, + bump: &'arena Bump, + source: &'arena str, + ) -> LexerResult> { + let mut tokens = BumpVec::with_capacity_in(self.capacity_hint, bump); + + let mut scanner = Scanner::new(source, &mut tokens); + scanner.lenient = true; + scanner.scan()?; + let open_string = scanner.open_string; + self.capacity_hint = self.capacity_hint.max(tokens.len()); + Ok(LenientTokens { + tokens, + open_string, + }) + } } struct Scanner<'arena, 'self_ref> { cursor: Cursor<'arena>, tokens: &'self_ref mut BumpVec<'arena, Token<'arena>>, source: &'arena str, + lenient: bool, + open_string: Option<(QuotationMark, u32)>, } impl<'arena, 'self_ref> Scanner<'arena, 'self_ref> { @@ -44,17 +70,47 @@ impl<'arena, 'self_ref> Scanner<'arena, 'self_ref> { cursor: Cursor::from(source), source, tokens, + lenient: false, + open_string: None, } } pub fn scan(&mut self) -> LexerResult<()> { while let Some(cursor_item) = self.cursor.peek() { - self.scan_cursor_item(cursor_item)?; + let pushed = self.tokens.len(); + match self.scan_cursor_item(cursor_item) { + Ok(()) => {} + Err(_) if self.lenient => { + self.tokens.truncate(pushed); + self.recover(cursor_item.0); + } + Err(error) => return Err(error), + } } Ok(()) } + fn recover(&mut self, start: usize) { + let consumed = self + .cursor + .peek() + .map(|(next, _)| next) + .unwrap_or(self.source.len()); + let mut end = consumed.max(start + 1).min(self.source.len()); + while !self.source.is_char_boundary(end) { + end += 1; + } + while self.cursor.peek().is_some_and(|(next, _)| next < end) { + self.cursor.next(); + } + self.push(Token { + kind: TokenKind::Operator(Operator::Semi), + span: (start as u32, end as u32), + value: &self.source[start..end], + }); + } + pub(crate) fn scan_cursor_item(&mut self, cursor_item: CursorItem) -> LexerResult<()> { let (i, s) = cursor_item; @@ -83,14 +139,16 @@ impl<'arena, 'self_ref> Scanner<'arena, 'self_ref> { } fn next(&self) -> LexerResult { - self.cursor.next().ok_or_else(|| { - let (a, b) = self.cursor.peek_back().unwrap_or((0, ' ')); + self.cursor.next().ok_or_else(|| self.eof_error()) + } - LexerError::UnexpectedEof { - symbol: b, - position: a as u32, - } - }) + fn eof_error(&self) -> LexerError { + let (a, b) = self.cursor.peek_back().unwrap_or((0, ' ')); + + LexerError::UnexpectedEof { + symbol: b, + position: a as u32, + } } fn push(&mut self, token: Token<'arena>) { @@ -111,6 +169,9 @@ impl<'arena, 'self_ref> Scanner<'arena, 'self_ref> { loop { let Some((e, c)) = self.cursor.next() else { let end = self.source.len(); + if self.lenient && !in_expression { + self.open_string = Some((QuotationMark::Backtick, start as u32)); + } if !in_expression && str_start < end { self.tokens.push(Token { kind: TokenKind::Literal, @@ -177,7 +238,14 @@ impl<'arena, 'self_ref> Scanner<'arena, 'self_ref> { } (_, true) => { self.cursor.back(); - self.scan_cursor_item((e, c))?; + let pushed = self.tokens.len(); + if let Err(error) = self.scan_cursor_item((e, c)) { + if !self.lenient { + return Err(error); + } + self.tokens.truncate(pushed); + self.recover(e); + } } } } @@ -187,16 +255,25 @@ impl<'arena, 'self_ref> Scanner<'arena, 'self_ref> { fn string(&mut self, quote_kind: QuotationMark) -> LexerResult<()> { let (start, opener) = self.next()?; - let end: usize; + let mut closed: Option = None; - loop { - let (e, c) = self.next()?; + while let Some((e, c)) = self.cursor.next() { if c == opener { - end = e; + closed = Some(e); break; } } + let (end, close_span) = match closed { + Some(e) => (e, (e as u32, (e + 1) as u32)), + None if self.lenient => { + let e = self.source.len(); + self.open_string = Some((quote_kind, start as u32)); + (e, (e as u32, e as u32)) + } + None => return Err(self.eof_error()), + }; + self.push(Token { kind: TokenKind::QuotationMark(quote_kind), span: (start as u32, (start + 1) as u32), @@ -211,7 +288,7 @@ impl<'arena, 'self_ref> Scanner<'arena, 'self_ref> { self.push(Token { kind: TokenKind::QuotationMark(quote_kind), - span: (end as u32, (end + 1) as u32), + span: close_span, value: quote_kind.into(), }); diff --git a/core/expression/src/lexer/mod.rs b/core/expression/src/lexer/mod.rs index f54af4fb..ce3beadc 100644 --- a/core/expression/src/lexer/mod.rs +++ b/core/expression/src/lexer/mod.rs @@ -4,10 +4,10 @@ mod error; mod token; -mod codes; +pub(crate) mod codes; mod cursor; mod lexer; pub use error::LexerError; -pub use lexer::Lexer; +pub use lexer::{LenientTokens, Lexer}; pub use token::*; diff --git a/core/expression/src/lib.rs b/core/expression/src/lib.rs index 6b7d9448..e5e9b061 100644 --- a/core/expression/src/lib.rs +++ b/core/expression/src/lib.rs @@ -65,9 +65,9 @@ pub mod expression; pub mod functions; pub mod intellisense; pub mod lexer; -pub mod nl; pub mod parser; pub mod scope; +pub mod slot; pub mod validate; pub mod variable; pub mod vm; diff --git a/core/expression/src/nl/mod.rs b/core/expression/src/nl/mod.rs deleted file mode 100644 index 7793db86..00000000 --- a/core/expression/src/nl/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -pub(crate) mod project; -pub mod token; - -pub use token::{EditHint, EnumOption, NlToken, NlTokenKind, OpChoice, OpSym, TypeTag, WordSym}; - -use serde::Serialize; -use std::rc::Rc; - -use crate::intellisense::diagnostic::Diagnostic; -use crate::intellisense::NlLabelResolver; -use crate::variable::VariableType; - -pub fn encode_string(value: &str) -> Option { - if !value.contains('"') { - Some(format!("\"{value}\"")) - } else if !value.contains('\'') { - Some(format!("'{value}'")) - } else { - None - } -} - -pub(crate) fn enum_options( - name: Option<&str>, - values: &[Rc], - labels: Option<&NlLabelResolver>, -) -> Vec { - values - .iter() - .map(|v| EnumOption { - label: labels - .zip(name) - .and_then(|(resolve, n)| resolve(n, v)) - .filter(|l| !l.is_empty()) - .unwrap_or_else(|| v.to_string()), - source: encode_string(v), - }) - .collect() -} - -pub(crate) fn subject_enum_options( - subject: &VariableType, - labels: Option<&NlLabelResolver>, -) -> Option> { - match subject { - VariableType::Enum(name, values) => Some(enum_options(name.as_deref(), values, labels)), - VariableType::Nullable(inner) | VariableType::Array(inner) => { - subject_enum_options(inner, labels) - } - _ => None, - } -} - -#[derive(Debug, Clone)] -pub struct NlRequest { - pub id: String, - pub expression: String, - pub unary: bool, - pub subject_type: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NlResult { - pub id: String, - pub tokens: Vec, - pub enums: Vec>, - pub diagnostics: Vec, - #[serde(skip)] - pub subject_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub subject_options: Option>, -} diff --git a/core/expression/src/nl/project.rs b/core/expression/src/nl/project.rs deleted file mode 100644 index 0114414a..00000000 --- a/core/expression/src/nl/project.rs +++ /dev/null @@ -1,951 +0,0 @@ -use std::rc::Rc; - -use crate::functions::{ClosureFunction, FunctionKind}; -use crate::intellisense::type_provider::TypesProvider; -use crate::intellisense::{AstMetadata, NlLabelResolver}; -use crate::lexer::{Bracket, ComparisonOperator, Operator}; -use crate::nl::token::{ - EditHint, EnumOption, NlToken, NlTokenKind, OpChoice, OpSym, TypeTag, WordSym, -}; -use crate::parser::Node; -use crate::variable::VariableType; - -struct AliasScope { - name: Option>, - elide: bool, -} - -pub(crate) struct Projector<'a> { - source: &'a str, - types: &'a TypesProvider, - metadata: &'a AstMetadata, - unary: bool, - labels: Option, - aliases: Vec, - pending_elide: bool, - pending_implied: bool, - enums: Vec>, - out: Vec, -} - -impl<'a> Projector<'a> { - pub(crate) fn new( - source: &'a str, - types: &'a TypesProvider, - metadata: &'a AstMetadata, - unary: bool, - labels: Option, - ) -> Self { - Self { - source, - types, - metadata, - unary, - labels, - aliases: Vec::new(), - pending_elide: false, - pending_implied: false, - enums: Vec::new(), - out: Vec::new(), - } - } - - pub(crate) fn run( - mut self, - root: &Node, - expected: Option, - ) -> (Vec, Vec>) { - self.project(root, expected); - (self.out, self.enums) - } - - fn project(&mut self, node: &Node, expected: Option) { - let span = self.span_of(node); - - match node { - Node::Null => self.push(NlTokenKind::Null, span), - Node::Bool(value) => self.push(NlTokenKind::Bool { value: *value }, span), - Node::Number(value) => self.push( - NlTokenKind::Number { - value: value.normalize().to_string().into_boxed_str(), - }, - span, - ), - Node::String(value) => { - let hint = match Self::enum_values(expected.as_ref()) { - Some((name, values)) => Some(EditHint::Select { - options: self.intern_enum(name.as_deref(), &values), - }), - None if Self::expects_date(expected.as_ref()) => Some(EditHint::DatePicker), - None => None, - }; - self.push_hint( - NlTokenKind::Str { - value: Box::from(*value), - }, - span, - hint, - ) - } - - Node::TemplateString(parts) => { - self.push(NlTokenKind::TemplateOpen, (span.0, span.0)); - for part in parts.iter() { - match part { - Node::String(value) => self.push( - NlTokenKind::TemplateText { - value: Box::from(*value), - }, - self.span_of(part), - ), - other => self.project(other, None), - } - } - self.push(NlTokenKind::TemplateClose, (span.1, span.1)); - } - - Node::Root => self.push(NlTokenKind::Root, span), - Node::Pointer => self.push(NlTokenKind::Element { alias: None }, span), - - Node::Identifier(name) => { - if *name == "$" { - self.push(NlTokenKind::Context, span); - } else if self - .aliases - .iter() - .any(|scope| scope.name.as_deref() == Some(*name)) - { - self.push( - NlTokenKind::Element { - alias: Some(Box::from(*name)), - }, - span, - ); - } else { - let ty = self.tag_of(&self.type_of(node)); - self.push( - NlTokenKind::Field { - path: vec![Box::from(*name)], - ty, - }, - span, - ); - } - } - - Node::Member { .. } => match Self::field_path(node) { - Some(field) => { - let field = self.strip_elided_alias(field); - let ty = self.tag_of(&self.type_of(node)); - self.push(NlTokenKind::Field { path: field, ty }, span); - } - None => self.code(span), - }, - - Node::Binary { - left, - operator, - right, - } => { - if let Some(sym) = self.contains_sym(*operator, right) { - self.contains_flipped(sym, left, right); - return; - } - let (exp_left, exp_right) = if matches!( - operator, - Operator::Logical(crate::lexer::LogicalOperator::NullishCoalescing) - ) { - (expected.clone(), expected) - } else { - self.operand_expectations(*operator, left, right) - }; - let context_subject = matches!(operator, Operator::Comparison(_)) - && matches!(left, Node::Identifier(name) if *name == "$"); - if !context_subject && !self.is_elided_subject(left) { - self.project(left, exp_left); - } - if let Some(sym) = OpSym::from_operator(*operator) { - let op_span = (self.span_of(left).1, self.span_of(right).0); - let hint = self.op_hint(*operator, left); - let implied = context_subject || std::mem::take(&mut self.pending_implied); - let between = matches!(sym, OpSym::In | OpSym::NotIn) - && matches!(right, Node::Interval { .. }); - self.push_hint( - NlTokenKind::Op { - sym, - implied, - between, - }, - op_span, - hint, - ); - } - self.project(right, exp_right); - } - - Node::Unary { - node: inner, - operator, - } => { - if let Some(sym) = OpSym::from_operator(*operator) { - let implied = std::mem::take(&mut self.pending_implied); - self.push( - NlTokenKind::Op { - sym, - implied, - between: false, - }, - (span.0, self.span_of(inner).0), - ); - } - self.project(inner, None); - } - - Node::Conditional { - condition, - on_true, - on_false, - } => { - self.push(NlTokenKind::Word { sym: WordSym::If }, (span.0, span.0)); - self.project(condition, None); - self.push( - NlTokenKind::Word { sym: WordSym::Then }, - (self.span_of(condition).1, self.span_of(on_true).0), - ); - self.project(on_true, expected.clone()); - self.push( - NlTokenKind::Word { - sym: WordSym::Otherwise, - }, - (self.span_of(on_true).1, self.span_of(on_false).0), - ); - self.project(on_false, expected); - } - - Node::Interval { - left, - right, - left_bracket, - right_bracket, - } => { - self.push( - NlTokenKind::IntervalOpen { - inclusive: *left_bracket == Bracket::LeftSquareBracket, - }, - (span.0, span.0), - ); - self.project(left, expected.clone()); - self.push( - NlTokenKind::Word { - sym: WordSym::RangeAnd, - }, - (self.span_of(left).1, self.span_of(right).0), - ); - self.project(right, expected); - self.push( - NlTokenKind::IntervalClose { - inclusive: *right_bracket == Bracket::RightSquareBracket, - }, - (span.1, span.1), - ); - } - - Node::Parenthesized(inner) => { - self.push(NlTokenKind::GroupOpen, (span.0, span.0)); - self.project(inner, expected); - self.push(NlTokenKind::GroupClose, (span.1, span.1)); - } - - Node::Array(items) => { - let enum_domain = Self::enum_values(expected.as_ref()) - .filter(|_| items.iter().all(|item| matches!(item, Node::String(_)))); - if let Some((name, values)) = enum_domain { - let selected = items - .iter() - .filter_map(|item| match item { - Node::String(value) => Some(Box::from(*value)), - _ => None, - }) - .collect(); - let hint = EditHint::MultiSelect { - options: self.intern_enum(name.as_deref(), &values), - }; - self.push_hint(NlTokenKind::EnumList { selected }, span, Some(hint)); - return; - } - self.push(NlTokenKind::ListOpen, (span.0, span.0)); - let item_expected = Self::item_expectation(expected.as_ref()); - for (i, item) in items.iter().enumerate() { - if i > 0 { - let prev = self.span_of(items[i - 1]); - self.push(NlTokenKind::Comma, (prev.1, self.span_of(item).0)); - } - self.project(item, item_expected.clone()); - } - self.push(NlTokenKind::ListClose, (span.1, span.1)); - } - - Node::FunctionCall { kind, arguments } => self.function_call(kind, arguments, span), - - Node::MethodCall { - kind, - this, - arguments, - } => { - self.project(this, None); - let this_end = self.span_of(this).1; - let sym = kind.to_string().into_boxed_str(); - let arg_expected = - Self::method_expects_date_arg(&sym).then_some(VariableType::Date); - self.push(NlTokenKind::Method { sym }, (this_end, span.1)); - if let [only] = arguments { - if Self::is_simple_arg(only) { - self.project(only, arg_expected); - return; - } - } - self.group_args_expected(arguments, this_end, span.1, arg_expected); - } - - Node::Closure { body, alias } => { - let elide = std::mem::take(&mut self.pending_elide); - self.aliases.push(AliasScope { - name: alias.map(Box::from), - elide, - }); - self.project(body, expected); - self.aliases.pop(); - } - - Node::Assignments { list, output } => { - for (i, (key, value)) in list.iter().enumerate() { - if i > 0 { - let prev_end = self.span_of(list[i - 1].1).1; - self.push(NlTokenKind::StmtEnd, (prev_end, self.span_of(key).0)); - } - match key { - Node::Identifier(name) | Node::String(name) => { - let ty = self.tag_of(&self.type_of(value)); - self.push( - NlTokenKind::Field { - path: vec![Box::from(*name)], - ty, - }, - self.span_of(key), - ); - } - other => self.project(other, None), - } - self.push( - NlTokenKind::Assign, - (self.span_of(key).1, self.span_of(value).0), - ); - self.project(value, None); - } - if let Some(output) = output { - if let Some(last) = list.last() { - self.push( - NlTokenKind::StmtEnd, - (self.span_of(last.1).1, self.span_of(output).0), - ); - } - self.project(output, expected); - } - } - - Node::Object(_) | Node::Slice { .. } | Node::Error { .. } => self.code(span), - } - } - - fn function_call(&mut self, kind: &FunctionKind, arguments: &[&Node], span: (u32, u32)) { - let sym = kind.to_string().into_boxed_str(); - let FunctionKind::Closure(cf) = kind else { - if Self::is_infix_predicate(&sym) && arguments.len() == 2 { - let (left, right) = (arguments[0], arguments[1]); - self.project(left, None); - self.push( - NlTokenKind::Func { - sym, - closure: false, - }, - (self.span_of(left).1, self.span_of(right).0), - ); - let needle_expected = match self.type_of(left) { - haystack @ VariableType::Array(_) => Some(haystack), - _ => None, - }; - self.project(right, needle_expected); - return; - } - if sym.as_ref() == "d" && matches!(arguments.first(), Some(Node::String(_))) { - if let [only] = arguments { - self.project(only, Some(VariableType::Date)); - return; - } - self.push( - NlTokenKind::Func { - sym, - closure: false, - }, - (span.0, span.0), - ); - self.group_args_expected(arguments, span.0, span.1, Some(VariableType::Date)); - return; - } - let suppressed = self.unary && sym.as_ref() == "bool" && self.out.is_empty(); - if !suppressed { - self.push( - NlTokenKind::Func { - sym, - closure: false, - }, - (span.0, span.0), - ); - } - if let [only] = arguments { - if suppressed || Self::is_simple_arg(only) { - self.project(only, None); - return; - } - } - self.group_args(arguments, span.0, span.1); - return; - }; - - if self.quantified_membership(*cf, arguments, span) { - return; - } - - let quant = Self::is_quantifier(*cf) - && arguments.len() == 2 - && matches!(arguments[1], Node::Closure { .. }); - let name_span = if quant { - (span.0, span.0 + sym.len() as u32) - } else { - (span.0, span.0) - }; - let hint = quant.then(|| EditHint::FuncSelect { - options: vec!["all".into(), "some".into(), "none".into()], - }); - self.push_hint(NlTokenKind::Func { sym, closure: true }, name_span, hint); - - let alias = match arguments.get(1) { - Some(Node::Closure { alias, .. }) => *alias, - _ => None, - }; - let elide = self.aliases.is_empty(); - if !elide { - self.push( - NlTokenKind::Element { - alias: alias.map(Box::from), - }, - (span.0, span.0), - ); - self.push(NlTokenKind::Word { sym: WordSym::In }, (span.0, span.0)); - } - let membership = self.closure_membership_expectation(arguments, alias); - if let Some(collection) = arguments.first() { - self.project(collection, membership); - } - let leftmost = match arguments.get(1) { - Some(Node::Closure { body, .. }) => Some(Self::leftmost_leaf(body)), - _ => None, - }; - let body_leads_with_subject = - elide && leftmost.is_some_and(|leaf| Self::is_binding_leaf(leaf, alias)); - if !body_leads_with_subject { - let sym = if elide && leftmost.is_some_and(|leaf| Self::is_binding_member(leaf, alias)) - { - self.pending_implied = true; - WordSym::Has - } else { - WordSym::Where - }; - self.push(NlTokenKind::Word { sym }, (span.1, span.1)); - } - if let Some(closure) = arguments.get(1) { - self.pending_elide = elide; - self.project(closure, None); - } - } - - fn method_expects_date_arg(sym: &str) -> bool { - matches!( - sym, - "isSame" | "isBefore" | "isAfter" | "isSameOrBefore" | "isSameOrAfter" | "diff" - ) - } - - fn is_infix_predicate(sym: &str) -> bool { - matches!( - sym, - "contains" | "startsWith" | "endsWith" | "matches" | "fuzzyMatch" - ) - } - - fn is_quantifier(cf: ClosureFunction) -> bool { - matches!( - cf, - ClosureFunction::All | ClosureFunction::Some | ClosureFunction::None - ) - } - - fn is_array(ty: &VariableType) -> bool { - match ty { - VariableType::Array(_) => true, - VariableType::Nullable(inner) => Self::is_array(inner), - _ => false, - } - } - - fn contains_sym(&self, operator: Operator, right: &Node) -> Option { - let Operator::Comparison(cmp) = operator else { - return None; - }; - let sym = match cmp { - ComparisonOperator::In => OpSym::Contains, - ComparisonOperator::NotIn => OpSym::NotContains, - _ => return None, - }; - if matches!(right, Node::Array(_) | Node::Interval { .. }) { - return None; - } - Self::is_array(&self.type_of(right)).then_some(sym) - } - - fn contains_flipped(&mut self, sym: OpSym, left: &Node, right: &Node) { - let context_subject = matches!(right, Node::Identifier(name) if *name == "$"); - if !context_subject && !self.is_elided_subject(right) { - self.project(right, None); - } - let op_span = (self.span_of(left).1, self.span_of(right).0); - let hint = EditHint::OpSelect { - options: vec![ - OpChoice::from(OpSym::Contains), - OpChoice::from(OpSym::NotContains), - ], - }; - let implied = context_subject || std::mem::take(&mut self.pending_implied); - self.push_hint( - NlTokenKind::Op { - sym, - implied, - between: false, - }, - op_span, - Some(hint), - ); - let expected = Some(self.type_of(right)); - self.project(left, expected); - } - - fn quantified_membership( - &mut self, - cf: ClosureFunction, - arguments: &[&Node], - span: (u32, u32), - ) -> bool { - if !Self::is_quantifier(cf) { - return false; - } - let [collection, closure] = arguments else { - return false; - }; - let Node::Closure { body, alias } = closure else { - return false; - }; - let mut node: &Node = body; - while let Node::Parenthesized(inner) = node { - node = inner; - } - let Node::Binary { - left, - operator: Operator::Comparison(cmp), - right, - } = node - else { - return false; - }; - let negated = match cmp { - ComparisonOperator::In => false, - ComparisonOperator::NotIn => true, - _ => return false, - }; - if !Self::is_binding_leaf(left, *alias) { - return false; - } - if !Self::membership_operand(right, *alias) { - return false; - } - let flipped = matches!(collection, Node::Array(_)) && !matches!(right, Node::Array(_)); - let (subject, list): (&Node, &Node) = if flipped { - (right, collection) - } else { - (collection, right) - }; - let subject_ty = self.type_of(subject); - if !matches!(subject_ty, VariableType::Any) && !Self::is_array(&subject_ty) { - return false; - } - let sym = match (flipped, cf, negated) { - (false, ClosureFunction::Some, false) => OpSym::ContainsAny, - (false, ClosureFunction::None, false) => OpSym::ContainsNone, - (false, ClosureFunction::All, false) => OpSym::ContainsOnly, - (false, ClosureFunction::All, true) => OpSym::ContainsNone, - (false, ClosureFunction::None, true) => OpSym::ContainsOnly, - (true, ClosureFunction::Some, false) => OpSym::ContainsAny, - (true, ClosureFunction::All, false) => OpSym::ContainsAll, - (true, ClosureFunction::None, false) => OpSym::ContainsNone, - (true, ClosureFunction::All, true) => OpSym::ContainsNone, - (true, ClosureFunction::None, true) => OpSym::ContainsAll, - _ => return false, - }; - let implied = matches!(subject, Node::Identifier(name) if *name == "$"); - if !implied { - self.project(subject, None); - } - let hint = EditHint::QuantSelect { - options: vec![ - OpSym::ContainsAny, - OpSym::ContainsAll, - OpSym::ContainsNone, - OpSym::ContainsOnly, - ], - subject: self.slice(self.span_of(subject)), - list: self.slice(self.span_of(list)), - }; - self.push_hint( - NlTokenKind::Op { - sym, - implied, - between: false, - }, - span, - Some(hint), - ); - self.project(list, Some(subject_ty)); - true - } - - fn membership_operand(node: &Node, alias: Option<&str>) -> bool { - match node { - Node::Array(items) => items - .iter() - .all(|item| matches!(item, Node::String(_) | Node::Number(_) | Node::Bool(_))), - Node::Identifier(name) => alias != Some(*name), - Node::Member { .. } => match Self::field_path(node) { - Some(path) => match (path.first(), alias) { - (Some(head), Some(bound)) => head.as_ref() != bound && head.as_ref() != "#", - (Some(head), None) => head.as_ref() != "#", - _ => false, - }, - None => false, - }, - _ => false, - } - } - - fn slice(&self, span: (u32, u32)) -> Box { - Box::from( - self.source - .get(span.0 as usize..span.1 as usize) - .unwrap_or_default(), - ) - } - - fn is_simple_arg(node: &Node) -> bool { - match node { - Node::Identifier(_) - | Node::Number(_) - | Node::String(_) - | Node::Bool(_) - | Node::Pointer - | Node::Array(_) => true, - Node::Member { .. } => Self::field_path(node).is_some(), - _ => false, - } - } - - fn closure_membership_expectation( - &self, - arguments: &[&Node], - alias: Option<&str>, - ) -> Option { - let Some(Node::Closure { body, .. }) = arguments.get(1) else { - return None; - }; - let mut node: &Node = body; - while let Node::Parenthesized(inner) = node { - node = inner; - } - let Node::Binary { - left, - operator: Operator::Comparison(cmp), - right, - } = node - else { - return None; - }; - if !matches!(cmp, ComparisonOperator::In | ComparisonOperator::NotIn) { - return None; - } - if !Self::is_binding_leaf(left, alias) { - return None; - } - let rhs = self.type_of(right); - Self::enum_values(Some(&rhs)).map(|(name, values)| VariableType::Enum(name, values)) - } - - fn leftmost_leaf<'n>(body: &'n Node<'n>) -> &'n Node<'n> { - let mut node = body; - loop { - match node { - Node::Binary { left, .. } => node = left, - Node::Parenthesized(inner) => node = inner, - other => return other, - } - } - } - - fn is_binding_leaf(leaf: &Node, alias: Option<&str>) -> bool { - match leaf { - Node::Pointer => alias.is_none(), - Node::Identifier(name) => alias == Some(*name), - _ => false, - } - } - - fn is_binding_member(leaf: &Node, alias: Option<&str>) -> bool { - match leaf { - Node::Member { .. } => match Self::field_path(leaf) { - Some(field) => match (field.first(), alias) { - (Some(head), Some(alias)) => head.as_ref() == alias, - (Some(head), None) => head.as_ref() == "#", - _ => false, - }, - None => false, - }, - _ => false, - } - } - - fn alias_elided(&self, name: Option<&str>) -> bool { - match name { - None => self - .aliases - .last() - .is_some_and(|scope| scope.elide && scope.name.is_none()), - Some(name) => self - .aliases - .iter() - .rev() - .find(|scope| scope.name.as_deref() == Some(name)) - .is_some_and(|scope| scope.elide), - } - } - - fn is_elided_subject(&self, node: &Node) -> bool { - match node { - Node::Pointer => self.alias_elided(None), - Node::Identifier(name) => self.alias_elided(Some(name)), - _ => false, - } - } - - fn strip_elided_alias(&self, field: Vec>) -> Vec> { - if field.len() < 2 { - return field; - } - let head = field[0].as_ref(); - let elided = self.alias_elided((head != "#").then_some(head)); - if elided { - field.into_iter().skip(1).collect() - } else { - field - } - } - - fn group_args(&mut self, arguments: &[&Node], open: u32, close: u32) { - self.group_args_expected(arguments, open, close, None); - } - - fn group_args_expected( - &mut self, - arguments: &[&Node], - open: u32, - close: u32, - mut first_expected: Option, - ) { - self.push(NlTokenKind::GroupOpen, (open, open)); - for (i, arg) in arguments.iter().enumerate() { - if i > 0 { - let prev = self.span_of(arguments[i - 1]); - self.push(NlTokenKind::Comma, (prev.1, self.span_of(arg).0)); - } - self.project(arg, first_expected.take()); - } - self.push(NlTokenKind::GroupClose, (close, close)); - } - - fn op_hint(&self, operator: Operator, left: &Node) -> Option { - use crate::lexer::ComparisonOperator as Cmp; - use crate::lexer::LogicalOperator as Log; - let options = match operator { - Operator::Comparison(Cmp::In | Cmp::NotIn) => vec![OpSym::In, OpSym::NotIn], - Operator::Comparison(_) => { - if Self::is_ordered(&self.type_of(left)) { - vec![ - OpSym::Gt, - OpSym::Gte, - OpSym::Lt, - OpSym::Lte, - OpSym::Eq, - OpSym::Ne, - ] - } else { - vec![OpSym::Eq, OpSym::Ne] - } - } - Operator::Logical(Log::And | Log::Or) => vec![OpSym::And, OpSym::Or], - Operator::Arithmetic(_) => vec![OpSym::Add, OpSym::Sub, OpSym::Mul, OpSym::Div], - _ => return None, - }; - Some(EditHint::OpSelect { - options: options.into_iter().map(OpChoice::from).collect(), - }) - } - - fn is_ordered(ty: &VariableType) -> bool { - match ty { - VariableType::Number | VariableType::Date | VariableType::Any => true, - VariableType::Nullable(inner) => Self::is_ordered(inner), - _ => false, - } - } - - fn operand_expectations( - &self, - operator: Operator, - left: &Node, - right: &Node, - ) -> (Option, Option) { - let Operator::Comparison(_) = operator else { - return (None, None); - }; - (Some(self.type_of(right)), Some(self.type_of(left))) - } - - fn code(&mut self, span: (u32, u32)) { - let source = self - .source - .get(span.0 as usize..span.1 as usize) - .unwrap_or_default(); - self.push( - NlTokenKind::Code { - source: Box::from(source), - }, - span, - ); - } - - fn push(&mut self, token: NlTokenKind, span: (u32, u32)) { - self.push_hint(token, span, None); - } - - fn push_hint(&mut self, token: NlTokenKind, span: (u32, u32), hint: Option) { - self.out.push(NlToken { token, span, hint }); - } - - fn span_of(&self, node: &Node) -> (u32, u32) { - let addr = node as *const Node as usize; - node.span() - .or_else(|| self.metadata.get(&addr).map(|m| m.span)) - .unwrap_or_default() - } - - fn type_of(&self, node: &Node) -> VariableType { - self.types - .get_type(node) - .map(|t| t.kind.clone()) - .unwrap_or(VariableType::Any) - } - - fn tag_of(&mut self, ty: &VariableType) -> TypeTag { - match ty { - VariableType::Number => TypeTag::Number, - VariableType::String | VariableType::Const(_) => TypeTag::String, - VariableType::Bool => TypeTag::Bool, - VariableType::Date => TypeTag::Date, - VariableType::Interval => TypeTag::Interval, - VariableType::Object(_) => TypeTag::Object, - VariableType::Null => TypeTag::Null, - VariableType::Any => TypeTag::Unknown, - VariableType::Enum(name, values) => TypeTag::Enum { - index: self.intern_enum(name.as_deref(), values), - }, - VariableType::Array(inner) => TypeTag::Array { - items: Box::new(self.tag_of(inner)), - }, - VariableType::Nullable(inner) => self.tag_of(inner), - } - } - - fn intern_enum(&mut self, name: Option<&str>, values: &[Rc]) -> u32 { - let options = crate::nl::enum_options(name, values, self.labels.as_ref()); - let existing = self.enums.iter().position(|e| *e == options); - if let Some(index) = existing { - return index as u32; - } - self.enums.push(options); - (self.enums.len() - 1) as u32 - } - - fn enum_values(ty: Option<&VariableType>) -> Option<(Option>, Vec>)> { - match ty? { - VariableType::Enum(name, values) => Some((name.clone(), values.clone())), - VariableType::Nullable(inner) | VariableType::Array(inner) => { - Self::enum_values(Some(inner)) - } - _ => None, - } - } - - fn expects_date(ty: Option<&VariableType>) -> bool { - match ty { - Some(VariableType::Date) => true, - Some(VariableType::Nullable(inner)) => Self::expects_date(Some(inner)), - _ => false, - } - } - - fn item_expectation(ty: Option<&VariableType>) -> Option { - match ty? { - VariableType::Array(inner) | VariableType::Nullable(inner) => { - Self::item_expectation(Some(inner)) - } - other => Some(other.shallow_clone()), - } - } - - fn field_path(node: &Node) -> Option>> { - match node { - Node::Identifier(name) => Some(vec![Box::from(*name)]), - Node::Pointer => Some(vec![Box::from("#")]), - Node::Root => Some(vec![Box::from("$root")]), - Node::Member { node, property } => { - let mut base = Self::field_path(node)?; - match property { - Node::String(s) => base.push(Box::from(*s)), - Node::Root => base.push(Box::from("$root")), - Node::Number(n) if n.is_integer() && !n.is_sign_negative() => { - let last = base.pop()?; - base.push(Box::from(format!("{last}[{n}]").as_str())); - } - _ => return None, - } - Some(base) - } - _ => None, - } - } -} diff --git a/core/expression/src/nl/token.rs b/core/expression/src/nl/token.rs deleted file mode 100644 index f049be0a..00000000 --- a/core/expression/src/nl/token.rs +++ /dev/null @@ -1,247 +0,0 @@ -use serde::Serialize; - -use crate::lexer::{ArithmeticOperator, ComparisonOperator, LogicalOperator, Operator}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NlToken { - pub token: NlTokenKind, - pub span: (u32, u32), - #[serde(skip_serializing_if = "Option::is_none")] - pub hint: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(tag = "t", rename_all = "camelCase")] -pub enum NlTokenKind { - GroupOpen, - GroupClose, - ListOpen, - ListClose, - Comma, - EnumList { - selected: Vec>, - }, - - Context, - Root, - Null, - - Field { - path: Vec>, - ty: TypeTag, - }, - Element { - #[serde(skip_serializing_if = "Option::is_none")] - alias: Option>, - }, - - Number { - value: Box, - }, - Str { - value: Box, - }, - Bool { - value: bool, - }, - - Op { - sym: OpSym, - implied: bool, - between: bool, - }, - Word { - sym: WordSym, - }, - Assign, - StmtEnd, - Func { - sym: Box, - closure: bool, - }, - Method { - sym: Box, - }, - - TemplateOpen, - TemplateText { - value: Box, - }, - TemplateClose, - - IntervalOpen { - inclusive: bool, - }, - IntervalClose { - inclusive: bool, - }, - - Code { - source: Box, - }, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(tag = "t", rename_all = "camelCase")] -pub enum TypeTag { - Number, - String, - Bool, - Date, - Interval, - Object, - Null, - Unknown, - Enum { index: u32 }, - Array { items: Box }, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum EditHint { - DatePicker, - Select { - options: u32, - }, - MultiSelect { - options: u32, - }, - OpSelect { - options: Vec, - }, - QuantSelect { - options: Vec, - subject: Box, - list: Box, - }, - FuncSelect { - options: Vec>, - }, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct OpChoice { - pub sym: OpSym, - pub source: &'static str, -} - -impl From for OpChoice { - fn from(sym: OpSym) -> Self { - Self { - sym, - source: sym.source(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EnumOption { - pub label: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum OpSym { - Gt, - Gte, - Lt, - Lte, - Eq, - Ne, - In, - NotIn, - Contains, - NotContains, - ContainsAny, - ContainsAll, - ContainsNone, - ContainsOnly, - Add, - Sub, - Mul, - Div, - Mod, - Pow, - And, - Or, - Not, - Coalesce, -} - -impl OpSym { - pub fn source(&self) -> &'static str { - match self { - OpSym::Gt => ">", - OpSym::Gte => ">=", - OpSym::Lt => "<", - OpSym::Lte => "<=", - OpSym::Eq => "==", - OpSym::Ne => "!=", - OpSym::In => "in", - OpSym::NotIn => "not in", - OpSym::Contains => "in", - OpSym::NotContains => "not in", - OpSym::ContainsAny => "some", - OpSym::ContainsAll => "all", - OpSym::ContainsNone => "none", - OpSym::ContainsOnly => "all", - OpSym::Add => "+", - OpSym::Sub => "-", - OpSym::Mul => "*", - OpSym::Div => "/", - OpSym::Mod => "%", - OpSym::Pow => "^", - OpSym::And => "and", - OpSym::Or => "or", - OpSym::Not => "not", - OpSym::Coalesce => "??", - } - } - - pub(crate) fn from_operator(operator: Operator) -> Option { - match operator { - Operator::Arithmetic(a) => Some(match a { - ArithmeticOperator::Add => OpSym::Add, - ArithmeticOperator::Subtract => OpSym::Sub, - ArithmeticOperator::Multiply => OpSym::Mul, - ArithmeticOperator::Divide => OpSym::Div, - ArithmeticOperator::Modulus => OpSym::Mod, - ArithmeticOperator::Power => OpSym::Pow, - }), - Operator::Logical(l) => Some(match l { - LogicalOperator::And => OpSym::And, - LogicalOperator::Or => OpSym::Or, - LogicalOperator::Not => OpSym::Not, - LogicalOperator::NullishCoalescing => OpSym::Coalesce, - }), - Operator::Comparison(c) => Some(match c { - ComparisonOperator::Equal => OpSym::Eq, - ComparisonOperator::NotEqual => OpSym::Ne, - ComparisonOperator::LessThan => OpSym::Lt, - ComparisonOperator::GreaterThan => OpSym::Gt, - ComparisonOperator::LessThanOrEqual => OpSym::Lte, - ComparisonOperator::GreaterThanOrEqual => OpSym::Gte, - ComparisonOperator::In => OpSym::In, - ComparisonOperator::NotIn => OpSym::NotIn, - }), - _ => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum WordSym { - If, - Then, - Otherwise, - In, - Where, - Has, - RangeAnd, -} diff --git a/core/expression/src/parser/ast.rs b/core/expression/src/parser/ast.rs index 0956f78b..51edb404 100644 --- a/core/expression/src/parser/ast.rs +++ b/core/expression/src/parser/ast.rs @@ -185,7 +185,10 @@ impl<'a> Node<'a> { AstNodeError::MissingToken { position, .. } => { Some((*position as u32, *position as u32)) } - AstNodeError::Custom { span, .. } => Some(span.clone()), + AstNodeError::ExpectedProperty { span } + | AstNodeError::ExpectedLiteral { span } + | AstNodeError::UnexpectedEnd { span } + | AstNodeError::Custom { span, .. } => Some(span.clone()), }, _ => None, } @@ -252,6 +255,15 @@ pub enum AstNodeError<'a> { #[error("Missing expected token: {expected} at {position}")] MissingToken { expected: &'a str, position: usize }, + #[error("Expected a property at ({}, {})", span.0, span.1)] + ExpectedProperty { span: (u32, u32) }, + + #[error("Expected a literal at ({}, {})", span.0, span.1)] + ExpectedLiteral { span: (u32, u32) }, + + #[error("Unexpected end of unary expression at ({}, {})", span.0, span.1)] + UnexpectedEnd { span: (u32, u32) }, + #[error("{message} at ({}, {})", span.0, span.1)] Custom { message: &'a str, span: (u32, u32) }, } diff --git a/core/expression/src/parser/mod.rs b/core/expression/src/parser/mod.rs index 82ff37b6..6cdfed56 100644 --- a/core/expression/src/parser/mod.rs +++ b/core/expression/src/parser/mod.rs @@ -13,7 +13,7 @@ mod result; mod standard; mod unary; -pub use ast::Node; +pub use ast::{AstNodeError, Node}; pub use constants::{Associativity, ParserOperator}; pub use error::ParserError; pub use parser::Parser; diff --git a/core/expression/src/parser/parser.rs b/core/expression/src/parser/parser.rs index b0c8264b..7c4c8601 100644 --- a/core/expression/src/parser/parser.rs +++ b/core/expression/src/parser/parser.rs @@ -451,8 +451,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> { let property = match property_token { None => self.error_with_node( - AstNodeError::Custom { - message: afmt!(self, "Expected a property"), + AstNodeError::ExpectedProperty { span: (self.prev_token_end(), self.prev_token_end()), }, node, @@ -497,8 +496,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> { let Some(mut c) = self.current() else { return self.error_with_node( - AstNodeError::Custom { - message: afmt!(self, "Expected a property"), + AstNodeError::ExpectedProperty { span: (self.prev_token_end(), self.prev_token_end()), }, node, @@ -1220,8 +1218,7 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> { F: Fn(ParserContext) -> &'arena Node<'arena>, { let Some(current_token) = self.current() else { - return self.error(AstNodeError::Custom { - message: afmt!(self, "Expected a literal"), + return self.error(AstNodeError::ExpectedLiteral { span: (self.prev_token_end(), self.prev_token_end()), }); }; diff --git a/core/expression/src/parser/standard.rs b/core/expression/src/parser/standard.rs index c6b35b93..0ccc1945 100644 --- a/core/expression/src/parser/standard.rs +++ b/core/expression/src/parser/standard.rs @@ -75,8 +75,7 @@ impl<'arena, 'token_ref> Parser<'arena, 'token_ref, Standard> { fn unary_expression(&self) -> &'arena Node<'arena> { let Some(token) = self.current() else { - return self.error(AstNodeError::Custom { - message: self.bump.alloc_str("Unexpected end of unary expression"), + return self.error(AstNodeError::UnexpectedEnd { span: (self.prev_token_end(), self.prev_token_end()), }); }; diff --git a/core/expression/src/slot/classify.rs b/core/expression/src/slot/classify.rs new file mode 100644 index 00000000..28ddcf3a --- /dev/null +++ b/core/expression/src/slot/classify.rs @@ -0,0 +1,2026 @@ +use std::rc::Rc; + +use crate::functions::{ + FunctionKind, FunctionRegistry, InternalFunction, MethodKind, MethodRegistry, +}; +use crate::intellisense::type_provider::TypesProvider; +use crate::lexer::codes::is_token_type; +use crate::lexer::{ + ArithmeticOperator, Bracket, ComparisonOperator, Identifier, LogicalOperator, Operator, + QuotationMark, TemplateString, Token, TokenKind, +}; +use crate::variable::VariableType; + +use super::literals::NodeTable; +use super::operators::Operators; +use super::{ + LabelResolver, Local, Parsed, Slot, SlotRole, SlotState, Span, ValueOption, VariableTypeSlot, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Str { quote: QuotationMark, open: bool }, + TemplateOpen, + TemplateClose, + TemplateText, + ExprStart, + ExprEnd, + Ident, + Ref(Identifier), + Number, + Bool, + Open(Bracket), + Close(Bracket), + Op(Operator), +} + +#[derive(Debug, Clone, Copy)] +struct Item { + first: usize, + last: usize, + span: Span, + body: Span, + kind: Kind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrameKind { + Call { + name: usize, + method: bool, + }, + Paren, + List { + after_in: Option, + implicit: bool, + }, + Index, + Interval, + Object, + Template, + TemplateExpr, +} + +#[derive(Debug, Clone, Copy)] +struct Frame { + open: usize, + kind: FrameKind, + commas: u32, +} + +pub(crate) struct Classifier<'p, 'a> { + parsed: Option<&'p Parsed<'a>>, + table: Option<&'p NodeTable<'a>>, + source: &'p str, + items: Vec, + partners: Vec>, + unary: bool, + role: SlotRole, + scope: &'p VariableType, + expected: Option<&'p VariableType>, + labels: Option<&'p LabelResolver>, + list_depth: std::cell::Cell, +} + +impl Item { + fn collect(tokens: &[Token]) -> Vec { + let mut items = Vec::with_capacity(tokens.len()); + let mut templates: Vec = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let t = &tokens[i]; + let single = Item { + first: i, + last: i, + span: t.span, + body: t.span, + kind: Kind::Ident, + }; + let kind = match t.kind { + TokenKind::QuotationMark( + q @ (QuotationMark::SingleQuote | QuotationMark::DoubleQuote), + ) => { + let closed = tokens + .get(i + 2) + .filter(|c| c.kind == TokenKind::QuotationMark(q)); + match (tokens.get(i + 1), closed) { + (Some(body), Some(close)) if body.kind == TokenKind::Literal => { + items.push(Item { + first: i, + last: i + 2, + span: (t.span.0, close.span.1), + body: body.span, + kind: Kind::Str { + quote: q, + open: close.span.0 == close.span.1, + }, + }); + i += 3; + continue; + } + _ => Kind::Str { + quote: q, + open: true, + }, + } + } + TokenKind::QuotationMark(QuotationMark::Backtick) => { + let zero_width = t.span.0 == t.span.1; + match templates.last() { + Some(false) => { + templates.pop(); + Kind::TemplateClose + } + Some(true) if zero_width => { + templates.pop(); + Kind::TemplateClose + } + _ => { + templates.push(false); + Kind::TemplateOpen + } + } + } + TokenKind::TemplateString(TemplateString::ExpressionStart) => { + if let Some(top) = templates.last_mut() { + *top = true; + } + Kind::ExprStart + } + TokenKind::TemplateString(TemplateString::ExpressionEnd) => { + if let Some(top) = templates.last_mut() { + *top = false; + } + let brace = (t.span.1, t.span.1 + 1); + items.push(Item { + span: brace, + body: brace, + kind: Kind::ExprEnd, + ..single + }); + i += 1; + continue; + } + TokenKind::Literal => { + if templates.last() == Some(&false) { + Kind::TemplateText + } else { + Kind::Ident + } + } + TokenKind::Identifier(id) => Kind::Ref(id), + TokenKind::Boolean(_) => Kind::Bool, + TokenKind::Number => Kind::Number, + TokenKind::Bracket(b) => match b { + Bracket::LeftParenthesis + | Bracket::LeftSquareBracket + | Bracket::LeftCurlyBracket => Kind::Open(b), + _ => Kind::Close(b), + }, + TokenKind::Operator(op) => { + let after_dot = i + .checked_sub(1) + .is_some_and(|k| tokens[k].kind == TokenKind::Operator(Operator::Dot)); + let keyword = matches!( + op, + Operator::Logical( + LogicalOperator::And | LogicalOperator::Or | LogicalOperator::Not + ) | Operator::Comparison(ComparisonOperator::In) + ); + if after_dot && keyword { + Kind::Ident + } else { + Kind::Op(op) + } + } + }; + items.push(Item { kind, ..single }); + i += 1; + } + items + } +} + +impl Item { + fn pair(items: &mut [Item]) -> Vec> { + let mut partners = vec![None; items.len()]; + let mut stack: Vec<(usize, FrameKind)> = Vec::new(); + let list = FrameKind::List { + after_in: None, + implicit: false, + }; + for j in 0..items.len() { + let before = j.checked_sub(1).map(|k| items[k].kind); + let after = items.get(j + 1).map(|it| it.kind); + let top = stack.last().map(|&(_, kind)| kind); + let encloses = stack.last().is_some_and(|&(open, _)| { + items[open + 1..j] + .iter() + .any(|it| it.kind == Kind::Op(Operator::Range)) + }); + let ranged = |kinds: &[FrameKind]| encloses && top.is_some_and(|t| kinds.contains(&t)); + let closes = match items[j].kind { + Kind::Open(Bracket::LeftParenthesis) => { + stack.push((j, FrameKind::Paren)); + false + } + Kind::Open(Bracket::LeftCurlyBracket) => { + stack.push((j, FrameKind::Object)); + false + } + Kind::TemplateOpen => { + stack.push((j, FrameKind::Template)); + false + } + Kind::Open(Bracket::LeftSquareBracket) => { + let closer = ranged(&[list, FrameKind::Interval]) + && after.is_none_or(|k| { + k.is_clause_boundary() + || matches!(k, Kind::Close(_) | Kind::ExprEnd | Kind::TemplateClose) + }); + if closer { + items[j].kind = Kind::Close(Bracket::LeftSquareBracket); + } else { + let kind = match before { + Some(k) if k.is_operand_end() => FrameKind::Index, + _ => list, + }; + stack.push((j, kind)); + } + closer + } + Kind::Close(Bracket::RightParenthesis) => { + top == Some(FrameKind::Paren) || ranged(&[list, FrameKind::Interval]) + } + Kind::Close(Bracket::RightSquareBracket) => { + let closes = matches!(top, Some(FrameKind::Index)) || top == Some(list); + let closes = closes || ranged(&[FrameKind::Paren, FrameKind::Interval]); + let opens = !closes + && before.is_none_or(|k| { + k.is_clause_boundary() + || matches!( + k, + Kind::Op(Operator::Comparison( + ComparisonOperator::In | ComparisonOperator::NotIn + )) + ) + }); + if opens { + items[j].kind = Kind::Open(Bracket::RightSquareBracket); + stack.push((j, FrameKind::Interval)); + } + closes + } + Kind::Close(Bracket::RightCurlyBracket) => top == Some(FrameKind::Object), + Kind::TemplateClose => top == Some(FrameKind::Template), + _ => false, + }; + if let Some((open, _)) = closes.then(|| stack.pop()).flatten() { + partners[open] = Some(j); + partners[j] = Some(open); + } + } + partners + } +} + +impl Kind { + fn is_operand_end(self) -> bool { + matches!( + self, + Kind::Str { .. } + | Kind::TemplateClose + | Kind::Ident + | Kind::Ref(_) + | Kind::Number + | Kind::Bool + | Kind::Close(_) + ) + } + + fn is_clause_boundary(self) -> bool { + matches!( + self, + Kind::Op(Operator::Logical(_)) + | Kind::Op(Operator::Comma) + | Kind::Op(Operator::QuestionMark) + | Kind::Op(Operator::Slice) + | Kind::Op(Operator::Assign) + | Kind::Op(Operator::Semi) + | Kind::Open(Bracket::LeftParenthesis) + | Kind::Open(Bracket::LeftCurlyBracket) + | Kind::ExprStart + ) + } + + fn is_word(self) -> bool { + matches!( + self, + Kind::Ident + | Kind::Ref(_) + | Kind::Number + | Kind::Bool + | Kind::Op(Operator::Logical( + LogicalOperator::And | LogicalOperator::Or | LogicalOperator::Not + )) + | Kind::Op(Operator::Comparison( + ComparisonOperator::In | ComparisonOperator::NotIn + )) + ) + } + + fn is_literal_start(self) -> bool { + matches!( + self, + Kind::Str { .. } + | Kind::TemplateOpen + | Kind::Number + | Kind::Bool + | Kind::Ref(Identifier::Null) + | Kind::Open(Bracket::LeftSquareBracket | Bracket::RightSquareBracket) + | Kind::Op(Operator::Arithmetic( + ArithmeticOperator::Add | ArithmeticOperator::Subtract + )) + ) + } +} + +impl<'p, 'a> Classifier<'p, 'a> { + pub(crate) fn classify( + parsed: &'p Parsed<'a>, + table: &'p NodeTable<'a>, + pos: u32, + unary: bool, + role: SlotRole, + expected: Option<&'p VariableType>, + labels: Option<&'p LabelResolver>, + ) -> Slot { + let (role, expected) = Self::effective_role(parsed.source, pos, role, expected); + Self::new(parsed, table, unary, role, expected, labels).classify_at(pos) + } + + pub(crate) fn fallback( + source: &'p str, + pos: u32, + unary: bool, + role: SlotRole, + scope: &'p VariableType, + expected: Option<&'p VariableType>, + labels: Option<&'p LabelResolver>, + ) -> Slot { + let (role, expected) = Self::effective_role(source, pos, role, expected); + let classifier = Self { + parsed: None, + table: None, + source, + items: Vec::new(), + partners: Vec::new(), + unary, + role, + scope, + expected, + labels, + list_depth: std::cell::Cell::new(0), + }; + classifier.classify_at(pos) + } + + pub(crate) fn closure_locals( + parsed: &'p Parsed<'a>, + table: &'p NodeTable<'a>, + pos: u32, + ) -> Vec<(Rc, VariableType)> { + let classifier = Self::new(parsed, table, false, SlotRole::Condition, None, None); + let limit = match classifier.string_at(pos) { + Some((idx, ..)) => idx, + None => match classifier.word_at(pos) { + Some(w) => classifier.not_in_head(w), + None => classifier.operand_limit(pos), + }, + }; + classifier.locals(limit) + } + + pub(crate) fn expected_at(&self, start: u32) -> Option { + let idx = self.items.iter().position(|it| it.span.0 == start)?; + if let Some(right) = self.comparison_after(idx) { + let end = self.run_end(right)?; + return self + .operand_type(right, end) + .and_then(VariableTypeSlot::declared); + } + let slot = self.at(start, idx); + let method_arg = slot.state == SlotState::Argument + && self + .frames(idx) + .last() + .is_some_and(|f| matches!(f.kind, FrameKind::Call { method: true, .. })); + if method_arg { + return None; + } + slot.expected + } + + fn effective_role( + source: &str, + pos: u32, + role: SlotRole, + expected: Option<&'p VariableType>, + ) -> (SlotRole, Option<&'p VariableType>) { + if role != SlotRole::Path + || Self::is_path_prefix(source[..pos as usize].trim_start().as_bytes()) + { + return (role, expected); + } + (SlotRole::Value, None) + } + + fn is_path_prefix(bytes: &[u8]) -> bool { + bytes.iter().all(|b| b.is_ascii_whitespace()) + || bytes.first().is_none_or(|b| Self::is_ident_byte(*b)) + && bytes.iter().all(|b| Self::is_ident_byte(*b) || *b == b'.') + } + + fn is_bool(t: &VariableType) -> bool { + matches!(t.unwrap_nullable().0, VariableType::Bool) + } + + fn widen(t: VariableType) -> VariableType { + match t { + VariableType::Const(_) => VariableType::String, + other => other, + } + } + + fn is_ordered(t: &VariableType) -> bool { + matches!( + t.unwrap_nullable().0, + VariableType::Number | VariableType::Date + ) + } + + fn is_ident_byte(b: u8) -> bool { + is_token_type!(b as char, "alphanumeric") + } + + pub(crate) fn new( + parsed: &'p Parsed<'a>, + table: &'p NodeTable<'a>, + unary: bool, + role: SlotRole, + expected: Option<&'p VariableType>, + labels: Option<&'p LabelResolver>, + ) -> Self { + let mut items = Item::collect(parsed.tokens); + let partners = Item::pair(&mut items); + Self { + parsed: Some(parsed), + table: Some(table), + source: parsed.source, + items, + partners, + unary, + role, + scope: &parsed.scope, + expected, + labels, + list_depth: std::cell::Cell::new(0), + } + } + + fn classify_at(&self, pos: u32) -> Slot { + let mut slot = self.run(pos); + slot.can_chain = matches!(self.role, SlotRole::Condition | SlotRole::Unary) + && !self.frames(self.operand_limit(pos)).iter().any(|f| { + matches!( + f.kind, + FrameKind::Call { .. } + | FrameKind::List { .. } + | FrameKind::Index + | FrameKind::Object + ) + }); + if matches!( + slot.state, + SlotState::Value | SlotState::Argument | SlotState::ListElement + ) { + let mut expected = slot.expected.as_ref(); + let mut arrays = 0; + while let Some(t) = expected { + match t { + VariableType::Nullable(inner) => expected = Some(inner), + VariableType::Array(inner) => { + arrays += 1; + expected = Some(inner); + } + _ => break, + } + } + if arrays > 0 { + for option in &mut slot.options { + if !option.is_null() { + if let Some(source) = &mut option.source { + *source = + format!("{}{}{}", "[".repeat(arrays), source, "]".repeat(arrays)); + } + } + } + } + } + slot + } + + fn run(&self, pos: u32) -> Slot { + if self.role == SlotRole::Path { + return self.path_slot(pos); + } + if self.in_decimal(pos) { + let mut slot = Slot::new(SlotState::Value, (pos, pos)); + slot.suppress_completions = true; + return slot; + } + + let limit = self.operand_limit(pos); + if let Some(frame) = self.frames(limit).last() { + if let FrameKind::Call { + name, + method: false, + } = frame.kind + { + if frame.commas == 0 + && self.is_closure(name) + && self + .collection_end(frame) + .and_then(|end| self.items.get(end + 1)) + .is_some_and(|item| self.is_as(item) && item.span.1 < pos) + { + let span = self + .word_at(pos) + .map(|i| self.items[i].span) + .unwrap_or((pos, pos)); + let mut slot = Slot::new(SlotState::Start, span); + slot.suppress_completions = true; + return slot; + } + } + } + + if let Some((idx, quote, replace)) = self.string_at(pos) { + return self.string_slot(idx, quote, replace, pos); + } + + if let Some(slot) = self + .items + .iter() + .position(|it| it.span.1 == pos) + .and_then(|p| self.operator_prefix(p)) + { + return slot; + } + + let word = self.word_at(pos); + let (eff, limit, replace) = match word { + Some(w) => { + let first = self.not_in_head(w); + ( + self.items[first].span.0, + first, + (self.items[first].span.0, self.items[w].span.1), + ) + } + None => (pos, self.operand_limit(pos), (pos, pos)), + }; + let inside = word.is_none() && self.items.get(limit).is_some_and(|it| it.span.0 < pos); + let mut slot = self.at(eff, limit); + slot.replace_span = replace; + slot.locals = self.slot_locals(limit); + if inside { + slot.auto_open = false; + if let Some(op) = self + .items + .get(limit) + .filter(|it| Self::is_symbol_operator(it.kind)) + { + slot.replace_span = op.span; + slot.options.clear(); + } + } + if word.is_none() && !inside { + self.span_token_ahead(&mut slot, pos); + } + let end = slot.replace_span.1.max(pos); + let next = self.items.partition_point(|it| it.span.0 < end); + let before = match word { + Some(_) => limit.checked_sub(1), + None => self + .items + .partition_point(|it| it.span.1 <= pos) + .checked_sub(1), + }; + let binds_to_operand = self.items.get(next).is_some_and(|it| { + (it.span.0 == end + && matches!( + it.kind, + Kind::Op(Operator::Dot) + | Kind::Open(Bracket::LeftParenthesis | Bracket::LeftSquareBracket) + )) + || Self::is_binary_after_operand(it.kind) + && !(Self::is_loose_operator(it.kind) + && before.is_some_and(|b| { + matches!(self.items[b].kind, Kind::Op(Operator::Comparison(_))) + })) + }); + if binds_to_operand && matches!(slot.state, SlotState::Value | SlotState::Start) { + slot.options.clear(); + slot.expected = None; + } + let operand_due = matches!( + slot.state, + SlotState::Start | SlotState::Argument | SlotState::Closure + ); + if operand_due && word.is_none() && !inside { + slot.auto_open = true; + } + slot + } + + fn string_slot(&self, idx: usize, quote: char, replace: Span, pos: u32) -> Slot { + let item = &self.items[idx]; + let body = if quote == '`' { + self.text((item.span.1, pos)).to_lowercase() + } else { + self.text((item.body.0, pos.min(item.body.1))) + .to_lowercase() + }; + let mut slot = self.at(item.span.0, idx); + slot.locals = self.slot_locals(idx); + if matches!(slot.state, SlotState::UnaryStart | SlotState::ListElement) + && matches!(item.kind, Kind::Str { open: false, .. }) + { + self.unlist(&mut slot, self.text(item.body)); + } + slot.options.retain(|o| !o.is_null()); + slot.operators.clear(); + if !body.is_empty() { + slot.options.retain(|o| { + o.value.to_lowercase().starts_with(&body) + || o.label.to_lowercase().starts_with(&body) + }); + } + if slot.state == SlotState::Operator { + slot.auto_open = false; + } + slot.state = SlotState::InString; + slot.in_string = Some(quote); + slot.replace_span = replace; + slot + } + + fn operand_ends_at(&self, pos: u32) -> bool { + self.items + .iter() + .any(|it| it.span.1 == pos && it.kind.is_operand_end()) + } + + fn is_loose_operator(kind: Kind) -> bool { + matches!( + kind, + Kind::Op(Operator::QuestionMark) + | Kind::Op(Operator::Logical(LogicalOperator::NullishCoalescing)) + ) + } + + fn is_operand_state(state: SlotState) -> bool { + matches!( + state, + SlotState::Start + | SlotState::UnaryStart + | SlotState::Value + | SlotState::ListElement + | SlotState::Range + | SlotState::Argument + | SlotState::Closure + | SlotState::Member + ) + } + + fn is_symbol_operator(kind: Kind) -> bool { + matches!( + kind, + Kind::Op(Operator::Comparison( + ComparisonOperator::Equal + | ComparisonOperator::NotEqual + | ComparisonOperator::LessThan + | ComparisonOperator::LessThanOrEqual + | ComparisonOperator::GreaterThan + | ComparisonOperator::GreaterThanOrEqual + )) | Kind::Op(Operator::Logical(LogicalOperator::NullishCoalescing)) + ) + } + + fn is_binary_after_operand(kind: Kind) -> bool { + matches!( + kind, + Kind::Op( + Operator::Comparison(_) + | Operator::Arithmetic(_) + | Operator::QuestionMark + | Operator::Range + | Operator::Logical(LogicalOperator::NullishCoalescing) + ) + ) + } + + fn span_token_ahead(&self, slot: &mut Slot, pos: u32) { + if self.operand_ends_at(pos) { + return; + } + let Some(idx) = self.items.iter().position(|it| it.span.0 == pos) else { + return; + }; + let item = self.items[idx]; + match item.kind { + kind if Self::is_symbol_operator(kind) => { + if matches!(slot.state, SlotState::Operator | SlotState::UnaryStart) { + slot.replace_span = item.span; + slot.options.clear(); + } + } + Kind::Op(Operator::Logical(LogicalOperator::And | LogicalOperator::Or)) + if slot.state == SlotState::Logical => + { + slot.replace_span = item.span; + } + Kind::Op(Operator::Comparison(ComparisonOperator::In | ComparisonOperator::NotIn)) + if slot.state == SlotState::Operator => + { + slot.replace_span = item.span; + } + Kind::Ident | Kind::Ref(_) | Kind::Number | Kind::Bool + if slot.state == SlotState::UnaryStart => + { + slot.options.clear(); + } + Kind::Ident | Kind::Ref(_) | Kind::Number | Kind::Bool + if Self::is_operand_state(slot.state) => + { + slot.replace_span = (pos, item.span.1); + } + Kind::Str { .. } + if matches!( + slot.state, + SlotState::Value + | SlotState::UnaryStart + | SlotState::ListElement + | SlotState::Argument + | SlotState::Start + ) => + { + if matches!(slot.state, SlotState::UnaryStart | SlotState::ListElement) + && matches!(item.kind, Kind::Str { open: false, .. }) + { + self.unlist(slot, self.text(item.body)); + } + slot.replace_span = item.span; + slot.operators.clear(); + } + Kind::Open(Bracket::LeftSquareBracket) + if matches!(slot.state, SlotState::Value | SlotState::Start) => + { + if let Some(close) = self.matched_close(idx) { + slot.replace_span = (pos, self.items[close].span.1); + } + } + _ => {} + } + } + + fn comparison_after(&self, idx: usize) -> Option { + let mut next = idx + 1; + if self.items.get(next)?.kind == Kind::Op(Operator::Logical(LogicalOperator::Not)) { + next += 1; + } + match self.items.get(next)?.kind { + Kind::Op(Operator::Comparison(_)) => Some(next + 1), + _ => None, + } + } + + fn in_decimal(&self, pos: u32) -> bool { + let before = self.text((0, pos)); + let fraction = before.trim_end_matches(|c: char| c.is_ascii_digit()); + let Some(integer) = fraction.strip_suffix('.') else { + return false; + }; + let head = integer.trim_end_matches(|c: char| c.is_ascii_digit() || c == '_'); + head.len() < integer.len() + && !head.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '$' | '#' | '.')) + && self.string_at(pos).is_none() + } + + fn text(&self, span: Span) -> &str { + self.source + .get(span.0 as usize..span.1 as usize) + .unwrap_or_default() + } + + fn path_slot(&self, pos: u32) -> Slot { + let bytes = self.source.as_bytes(); + let mut start = pos as usize; + while start > 0 && Self::is_ident_byte(bytes[start - 1]) { + start -= 1; + } + let mut end = pos as usize; + while end < bytes.len() && Self::is_ident_byte(bytes[end]) { + end += 1; + } + let mut slot = Slot::new(SlotState::Path, (start as u32, end as u32)); + slot.auto_open = self.source.trim().is_empty(); + if start > 0 && bytes[start - 1] == b'.' { + if let Some(dot) = self + .items + .iter() + .position(|i| i.span.0 == (start - 1) as u32) + { + slot.operand = self.member(dot).operand; + } + } + slot + } + fn not_in_head(&self, w: usize) -> usize { + let is_in = self.items[w].kind == Kind::Op(Operator::Comparison(ComparisonOperator::In)) + || (self.items[w].kind == Kind::Ident && self.text(self.items[w].span) == "i"); + match w.checked_sub(1) { + Some(k) + if is_in + && self.items[k].kind == Kind::Op(Operator::Logical(LogicalOperator::Not)) => + { + k + } + _ => w, + } + } + + fn unlist(&self, slot: &mut Slot, value: &str) { + let Some(i) = slot.listed.iter().position(|v| v == value) else { + return; + }; + slot.listed.remove(i); + slot.options = slot + .operand + .as_ref() + .and_then(|s| ValueOption::for_type(s, self.labels)) + .unwrap_or_default(); + slot.options.retain(|o| !slot.listed.contains(&o.value)); + } + fn operand_limit(&self, pos: u32) -> usize { + self.items + .iter() + .position(|it| it.span.1 > pos || (it.span.0 == it.span.1 && it.span.0 >= pos)) + .unwrap_or(self.items.len()) + } + + fn slot_locals(&self, limit: usize) -> Vec { + self.locals(limit) + .into_iter() + .map(|(name, kind)| Local { + name: name.to_string(), + kind, + }) + .collect() + } + fn locals(&self, limit: usize) -> Vec<(Rc, VariableType)> { + let frames = self.frames(limit); + let mut out: Vec<(Rc, VariableType)> = Vec::new(); + for frame in frames.iter().rev() { + let FrameKind::Call { + name, + method: false, + } = frame.kind + else { + continue; + }; + if !self.is_closure(name) || frame.commas == 0 { + continue; + } + let name: Rc = self + .collection_end(frame) + .and_then(|end| self.closure_alias(end)) + .map(Rc::from) + .unwrap_or_else(|| Rc::from("#")); + if out.iter().any(|(n, _)| *n == name) { + continue; + } + let element = self.element_type(frame).unwrap_or(VariableType::Any); + out.push((name, element)); + } + out + } + fn collection_end(&self, frame: &Frame) -> Option { + let end = self.run_end(frame.open + 1)?; + let mut depth = 0u32; + for i in frame.open + 2..=end { + match self.items[i].kind { + Kind::Open(_) | Kind::TemplateOpen => depth += 1, + Kind::Close(_) | Kind::TemplateClose => depth = depth.saturating_sub(1), + _ if depth == 0 + && self.items[i - 1].kind != Kind::Op(Operator::Dot) + && self.is_as(&self.items[i]) => + { + return Some(i - 1); + } + _ => {} + } + } + Some(end) + } + + fn closure_alias(&self, end: usize) -> Option<&str> { + let as_word = self.items.get(end + 1)?; + if !self.is_as(as_word) { + return None; + } + let alias = self + .items + .get(end + 2) + .filter(|it| it.kind == Kind::Ident)?; + Some(self.text(alias.span)) + } + + fn word_at(&self, pos: u32) -> Option { + self.items + .iter() + .position(|it| it.span.0 < pos && pos <= it.span.1 && self.is_word_item(it)) + } + + fn is_word_item(&self, item: &Item) -> bool { + item.kind.is_word() + && (!matches!(item.kind, Kind::Op(_)) + || self.text(item.span).chars().any(char::is_alphabetic)) + } + + fn string_at(&self, pos: u32) -> Option<(usize, char, Span)> { + for (i, it) in self.items.iter().enumerate() { + if let Kind::Str { quote, .. } = it.kind { + if it.body.0 <= pos && pos <= it.body.1 { + let quote = match quote { + QuotationMark::SingleQuote => '\'', + QuotationMark::DoubleQuote => '"', + QuotationMark::Backtick => '`', + }; + return Some((i, quote, it.span)); + } + } + } + + let mut stack: Vec<(usize, bool)> = Vec::new(); + for (i, it) in self.items.iter().enumerate() { + if it.span.0 > pos { + break; + } + match it.kind { + Kind::TemplateOpen if it.span.1 <= pos => stack.push((i, false)), + Kind::TemplateClose if it.span.1 <= pos && it.span.0 < it.span.1 => { + stack.pop(); + } + Kind::ExprStart if it.span.1 <= pos => { + if let Some(top) = stack.last_mut() { + top.1 = true; + } + } + Kind::ExprEnd if it.span.1 <= pos => { + if let Some(top) = stack.last_mut() { + top.1 = false; + } + } + _ => {} + } + } + let (open, in_expr) = *stack.last()?; + if in_expr { + return None; + } + let close = self.template_close(open); + let end = close + .map(|c| self.items[c].span.1) + .unwrap_or(self.source.len() as u32); + Some((open, '`', (self.items[open].span.0, end))) + } + + fn template_close(&self, open: usize) -> Option { + let mut depth = 0; + for (i, it) in self.items.iter().enumerate().skip(open) { + match it.kind { + Kind::TemplateOpen => depth += 1, + Kind::TemplateClose => { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None + } + + fn frames(&self, limit: usize) -> Vec { + let mut frames: Vec = Vec::new(); + for j in 0..limit { + let before = j.checked_sub(1).map(|k| self.items[k].kind); + match self.items[j].kind { + Kind::Open(Bracket::LeftParenthesis) => { + let kind = match before { + Some(Kind::Ident) => FrameKind::Call { + name: j - 1, + method: j >= 2 && self.items[j - 2].kind == Kind::Op(Operator::Dot), + }, + _ => FrameKind::Paren, + }; + frames.push(Frame { + open: j, + kind, + commas: 0, + }); + } + Kind::Close(_) | Kind::TemplateClose => { + let paired = self.partners[j] + .is_some_and(|open| frames.last().is_some_and(|f| f.open == open)); + if paired { + frames.pop(); + } + } + Kind::Open(Bracket::RightSquareBracket) => frames.push(Frame { + open: j, + kind: FrameKind::Interval, + commas: 0, + }), + Kind::Open(Bracket::LeftSquareBracket) => { + let kind = match before { + Some(k) if k.is_operand_end() => FrameKind::Index, + Some(Kind::Op(Operator::Comparison( + ComparisonOperator::In | ComparisonOperator::NotIn, + ))) => FrameKind::List { + after_in: Some(j - 1), + implicit: false, + }, + other => FrameKind::List { + after_in: None, + implicit: self.unary + && frames.is_empty() + && other.is_none_or(Kind::is_clause_boundary), + }, + }; + frames.push(Frame { + open: j, + kind, + commas: 0, + }); + } + Kind::Open(Bracket::LeftCurlyBracket) => frames.push(Frame { + open: j, + kind: FrameKind::Object, + commas: 0, + }), + Kind::TemplateOpen => frames.push(Frame { + open: j, + kind: FrameKind::Template, + commas: 0, + }), + Kind::ExprStart => frames.push(Frame { + open: j, + kind: FrameKind::TemplateExpr, + commas: 0, + }), + Kind::ExprEnd => { + if frames + .last() + .is_some_and(|f| f.kind == FrameKind::TemplateExpr) + { + frames.pop(); + } + } + Kind::Op(Operator::Comma) => { + if let Some(top) = frames.last_mut() { + top.commas += 1; + } + } + _ => {} + } + } + frames + } + + fn is_interval(&self, open: usize) -> bool { + self.matched_close(open).is_some_and(|close| { + self.items[open + 1..close] + .iter() + .any(|it| it.kind == Kind::Op(Operator::Range)) + }) + } + + fn at(&self, eff: u32, limit: usize) -> Slot { + let frames = self.frames(limit); + let mut prev = limit.checked_sub(1); + + while let Some(p) = prev { + let unmatched = + matches!(self.items[p].kind, Kind::Close(_)) && self.matched_open(p).is_none(); + if !unmatched { + break; + } + prev = p.checked_sub(1); + } + + let mut signed = false; + while let Some(p) = prev { + let sign = matches!( + self.items[p].kind, + Kind::Op(Operator::Arithmetic( + ArithmeticOperator::Add | ArithmeticOperator::Subtract + )) + ); + let unary_sign = sign + && !p + .checked_sub(1) + .is_some_and(|k| self.items[k].kind.is_operand_end()); + if !unary_sign { + break; + } + signed = true; + prev = p.checked_sub(1); + } + + let gap = prev.is_some_and(|p| self.items[p].span.1 < eff); + let mut slot = self.after(prev, gap, limit, &frames); + if signed { + match slot.state { + SlotState::UnaryStart => { + let subject = self.subject(); + slot = self.value_slot(Some(subject.shallow_clone()), Some(subject)); + } + SlotState::Start => slot.expected = Some(VariableType::Number), + _ => {} + } + } + slot + } + + fn after(&self, prev: Option, gap: bool, limit: usize, frames: &[Frame]) -> Slot { + let Some(p) = prev else { + return self.head(); + }; + + match self.items[p].kind { + Kind::Op(Operator::Dot) => self.member(p), + Kind::Op(Operator::Comparison(op)) => self.value_after(p, op), + Kind::Op(Operator::Range) => self.range_after(p, frames), + Kind::Open(Bracket::LeftSquareBracket) => self.list_head(limit, frames), + Kind::Open(Bracket::RightSquareBracket) => { + let subject = match p.checked_sub(1).map(|k| self.items[k].kind) { + Some(Kind::Op(Operator::Comparison( + ComparisonOperator::In | ComparisonOperator::NotIn, + ))) => self.comparison_left(p - 1), + _ if self.unary && frames.len() == 1 => Some(self.subject()), + _ => None, + }; + self.range_slot(None, subject) + } + Kind::Op(Operator::Comma) => match frames.last().map(|f| f.kind) { + Some(FrameKind::List { .. } | FrameKind::Index) => self.list_head(limit, frames), + Some(FrameKind::Call { .. }) => self.argument(frames), + Some(_) => self.start(None), + None if self.unary => self.unary_start(), + None => self.start(None), + }, + Kind::Open(Bracket::LeftParenthesis) => match frames.last().map(|f| f.kind) { + Some(FrameKind::Call { .. }) => self.argument(frames), + _ => { + let before = p.checked_sub(1).map(|k| self.items[k].kind); + if let Some(Kind::Op(Operator::Comparison( + ComparisonOperator::In | ComparisonOperator::NotIn, + ))) = before + { + let subject = self.comparison_left(p - 1); + return self.range_slot(None, subject); + } + let unary_interval = self.unary + && frames.len() == 1 + && before.is_none_or(Kind::is_clause_boundary) + && Self::is_ordered(&self.subject()); + if unary_interval { + return self.range_slot(None, Some(self.subject())); + } + let mut outer = p; + while let Some(k) = outer.checked_sub(1) { + if self.items[k].kind != Kind::Open(Bracket::LeftParenthesis) { + break; + } + outer = k; + } + let before = outer.checked_sub(1).map(|k| self.items[k].kind); + let inherited = match before { + Some(Kind::Op(Operator::Comparison(_))) => self.left_operand(outer - 1), + Some(Kind::Op(Operator::Logical( + LogicalOperator::And | LogicalOperator::Or | LogicalOperator::Not, + ))) => Some(VariableType::Bool), + Some(Kind::Op(Operator::Arithmetic(_))) => { + self.left_operand(outer - 1).or(Some(VariableType::Number)) + } + _ => self.whole_expected(frames, limit), + }; + let after_operator = matches!( + before, + Some(Kind::Op(Operator::Comparison(_) | Operator::Arithmetic(_))) + ); + match self.closure_frame(frames) { + _ if after_operator => self.start(inherited), + Some((_, name)) if self.is_mapping_closure(name) => { + self.closure_head(frames) + } + Some(_) => self.start(Some(VariableType::Bool)), + None => self.start(inherited), + } + } + }, + Kind::Op(Operator::Logical(LogicalOperator::And | LogicalOperator::Or)) => { + if frames.is_empty() && self.unary { + self.unary_start() + } else { + self.start(Some(VariableType::Bool)) + } + } + Kind::Op(Operator::Logical(LogicalOperator::Not)) => { + self.start(Some(VariableType::Bool)) + } + Kind::Op(Operator::Logical(LogicalOperator::NullishCoalescing)) => { + let left = self + .left_operand(p) + .map(|t| t.unwrap_nullable().0.shallow_clone()); + self.branch_slot(left.or_else(|| self.whole_expected(frames, limit))) + } + Kind::Op(Operator::QuestionMark) => { + self.branch_slot(self.whole_expected(frames, limit)) + } + Kind::Op(Operator::Slice) => match frames.last().map(|f| f.kind) { + Some(FrameKind::Index) => self.start(Some(VariableType::Number)), + _ => self.branch_slot(self.whole_expected(frames, limit)), + }, + Kind::Op(Operator::Semi) => self.start(self.whole_expected(frames, limit)), + Kind::Op(Operator::Arithmetic(_)) => { + let left = self.left_operand(p).map(Self::widen); + self.start(left) + } + Kind::Op(Operator::Assign) + | Kind::Open(Bracket::LeftCurlyBracket) + | Kind::ExprStart + | Kind::TemplateOpen + | Kind::TemplateText + | Kind::ExprEnd => self.start(None), + kind if kind.is_operand_end() => self.after_operand(p, gap, frames), + _ => self.start(None), + } + } + fn whole_expected(&self, frames: &[Frame], limit: usize) -> Option { + let mut current = self.expected?.shallow_clone(); + for (i, frame) in frames.iter().enumerate() { + let end = frames.get(i + 1).map(|f| f.open).unwrap_or(limit); + match frame.kind { + FrameKind::Paren if self.branch_paren(frame.open) => {} + FrameKind::List { after_in: None, .. } => { + current = current.innermost().shallow_clone(); + } + FrameKind::Object => { + let key = self.object_key(frame.open, end)?; + current = match current.get(key) { + VariableType::Any | VariableType::Null => return None, + t => t, + }; + } + _ => return None, + } + } + Some(current) + } + + fn branch_paren(&self, open: usize) -> bool { + open.checked_sub(1).is_none_or(|k| { + matches!( + self.items[k].kind, + Kind::Op(Operator::QuestionMark) + | Kind::Op(Operator::Slice) + | Kind::Op(Operator::Logical(LogicalOperator::NullishCoalescing)) + | Kind::Open(Bracket::LeftParenthesis) + ) + }) + } + fn object_key(&self, open: usize, end: usize) -> Option<&str> { + let mut depth = 0u32; + let mut key = None; + for j in open + 1..end { + match self.items[j].kind { + Kind::Open(_) | Kind::TemplateOpen => depth += 1, + Kind::Close(_) | Kind::TemplateClose => depth = depth.saturating_sub(1), + Kind::Op(Operator::Slice) if depth == 0 => { + let k = self.items[j - 1]; + key = match k.kind { + Kind::Ident | Kind::Str { open: false, .. } => Some(self.text(k.body)), + _ => None, + }; + } + _ => {} + } + } + key + } + + fn matched_open(&self, close: usize) -> Option { + self.partners.get(close).copied().flatten() + } + + fn branch_slot(&self, expected: Option) -> Slot { + let enumerable = expected + .as_ref() + .is_some_and(|e| ValueOption::for_type(e, self.labels).is_some()); + if expected.is_some() && (self.role == SlotRole::Value || enumerable) { + self.value_slot(expected, None) + } else { + self.start(expected) + } + } + + fn subject(&self) -> VariableType { + self.scope.get("$") + } + + fn head(&self) -> Slot { + if self.unary { + return self.unary_start(); + } + match (self.role, self.expected) { + (SlotRole::Value, Some(expected)) => { + self.value_slot(Some(expected.shallow_clone()), None) + } + _ => self.start(self.expected.map(|e| e.shallow_clone())), + } + } + + fn start(&self, expected: Option) -> Slot { + let mut slot = Slot::new(SlotState::Start, (0, 0)); + slot.expected = expected; + slot + } + + fn value_slot(&self, expected: Option, operand: Option) -> Slot { + let mut slot = Slot::new(SlotState::Value, (0, 0)); + slot.options = expected + .as_ref() + .and_then(|e| ValueOption::for_type(e, self.labels)) + .unwrap_or_default(); + if matches!(expected, Some(VariableType::Nullable(_))) { + slot.options.push(ValueOption::null()); + } + slot.expected = expected; + slot.operand = operand; + slot.auto_open = true; + slot + } + + fn unary_start(&self) -> Slot { + let subject = self.subject(); + let mut slot = Slot::new(SlotState::UnaryStart, (0, 0)); + slot.listed = self.unary_listed(); + let array = matches!(subject.unwrap_nullable().0, VariableType::Array(_)); + slot.options = ValueOption::for_type(&subject, self.labels) + .filter(|_| !array) + .unwrap_or_default(); + slot.options.retain(|o| !slot.listed.contains(&o.value)); + if matches!(subject, VariableType::Nullable(_)) + && !slot.listed.iter().any(|v| v == ValueOption::NULL) + { + slot.options.push(ValueOption::null()); + } + slot.operators = Operators::for_type(&subject, true); + slot.auto_open = !slot.options.is_empty() || !slot.operators.is_empty(); + slot.expected = Some(subject.shallow_clone()); + slot.operand = Some(subject); + slot + } + fn left_operand(&self, op: usize) -> Option { + let end = op.checked_sub(1)?; + if !self.items[end].kind.is_operand_end() { + return None; + } + let start = self.run_start(end, true); + self.operand_type(start, end) + } + fn operand_type(&self, start: usize, end: usize) -> Option { + let t = self.type_of_run(start, end, true)?; + let literal = start == end && matches!(self.items[start].kind, Kind::Str { .. }); + Some(if literal { Self::widen(t) } else { t }) + } + + fn comparison_left(&self, op: usize) -> Option { + if self.implicit_subject(op) { + Some(self.subject()) + } else { + self.left_operand(op) + } + } + + fn is_closure(&self, name: usize) -> bool { + matches!( + FunctionKind::try_from(self.text(self.items[name].span)), + Ok(FunctionKind::Closure(_)) + ) + } + + fn is_mapping_closure(&self, name: usize) -> bool { + matches!(self.text(self.items[name].span), "map" | "flatMap") + } + + fn is_as(&self, item: &Item) -> bool { + item.kind == Kind::Ident && self.text(item.span) == "as" + } + + fn implicit_subject(&self, op: usize) -> bool { + self.unary + && op + .checked_sub(1) + .is_none_or(|k| !self.items[k].kind.is_operand_end()) + } + fn operator_prefix(&self, p: usize) -> Option { + if !matches!( + self.items[p].kind, + Kind::Op(Operator::Comparison(_) | Operator::Assign | Operator::QuestionMark) + | Kind::Op(Operator::Logical(LogicalOperator::Not)) + ) { + return None; + } + let typed = self.text(self.items[p].span); + if typed.chars().all(|c| c.is_alphabetic()) { + return None; + } + let has_left = p + .checked_sub(1) + .is_some_and(|k| self.items[k].kind.is_operand_end()); + if !has_left && !self.unary { + return None; + } + let operand = self.comparison_left(p); + let operators: Vec<&'static str> = + Operators::for_type(operand.as_ref().unwrap_or(&VariableType::Any), false) + .into_iter() + .filter(|op| op.starts_with(typed)) + .collect(); + if operators.is_empty() { + return None; + } + let mut slot = Slot::new(SlotState::Operator, self.items[p].span); + slot.operators = operators; + slot.operand = operand; + slot.auto_open = true; + Some(slot) + } + + fn value_after(&self, p: usize, op: ComparisonOperator) -> Slot { + let left = self.comparison_left(p); + let expected = match (op, &left) { + (ComparisonOperator::In | ComparisonOperator::NotIn, Some(t)) => { + Some(VariableType::Array(Rc::new(t.shallow_clone()))) + } + (_, l) => l.clone(), + }; + let mut slot = self.value_slot(expected, left); + if !matches!(op, ComparisonOperator::Equal | ComparisonOperator::NotEqual) { + slot.options.retain(|o| !o.is_null()); + } + slot + } + + fn member(&self, p: usize) -> Slot { + let mut slot = Slot::new(SlotState::Member, (0, 0)); + if let Some(end) = p + .checked_sub(1) + .filter(|&k| self.items[k].kind.is_operand_end()) + { + let start = self.run_start(end, false); + slot.operand = self.type_of_run(start, end, true); + let head = self.at(self.items[start].span.0, start); + if head.wanted_scalar().is_some() { + slot.expected = head.expected; + } + } + slot + } + + fn list_subject(&self, frame: &Frame) -> Option { + match frame.kind { + FrameKind::List { + after_in: Some(op), .. + } => self + .comparison_left(op) + .filter(|t| !matches!(t.unwrap_nullable().0, VariableType::Array(_))), + FrameKind::List { implicit: true, .. } => Some(self.subject()) + .filter(|t| !matches!(t.unwrap_nullable().0, VariableType::Array(_))), + FrameKind::List { .. } => { + let before = frame.open.checked_sub(1).map(|k| self.items[k].kind); + match before { + None => self.expected.and_then(Self::array_element), + Some(Kind::Op(Operator::Comparison(_))) => self.left_operand(frame.open - 1), + Some(Kind::Open(Bracket::LeftParenthesis)) => self + .closure_membership(frame.open) + .or_else(|| self.list_expected(frame)), + _ => self.list_expected(frame), + } + } + _ => None, + } + } + + fn list_expected(&self, frame: &Frame) -> Option { + const MAX_LIST_DEPTH: u32 = 64; + let depth = self.list_depth.get(); + if depth >= MAX_LIST_DEPTH { + return None; + } + self.list_depth.set(depth + 1); + let head = self.at(self.items[frame.open].span.0, frame.open); + self.list_depth.set(depth); + head.expected + .as_ref() + .and_then(Self::array_element) + .filter(|t| !matches!(t, VariableType::Any)) + } + + fn array_element(t: &VariableType) -> Option { + match t.unwrap_nullable().0 { + VariableType::Array(_) => Some(t.innermost().shallow_clone()), + _ => None, + } + } + fn closure_membership(&self, open: usize) -> Option { + let paren = open.checked_sub(1)?; + let name = paren.checked_sub(1)?; + let closure = self.items[paren].kind == Kind::Open(Bracket::LeftParenthesis) + && self.items[name].kind == Kind::Ident + && self.is_closure(name); + if !closure { + return None; + } + let mut i = self.matched_close(open)? + 1; + let mut alias = None; + let as_word = self.items.get(i).is_some_and(|it| self.is_as(it)); + if as_word { + alias = Some(self.text(self.items.get(i + 1)?.span)); + i += 2; + } + if self.items.get(i)?.kind != Kind::Op(Operator::Comma) { + return None; + } + i += 1; + let binds = match (self.items.get(i)?.kind, alias) { + (Kind::Ref(Identifier::CallbackReference), None) => true, + (Kind::Ident, Some(a)) => self.text(self.items[i].span) == a, + _ => false, + }; + let right = self.comparison_after(i).filter(|_| binds)?; + let membership = matches!( + self.items[right - 1].kind, + Kind::Op(Operator::Comparison( + ComparisonOperator::In | ComparisonOperator::NotIn + )) + ); + if !membership { + return None; + } + let end = self.run_end(right)?; + let rhs = self.type_of_run(right, end, true)?; + rhs.enum_domain() + .map(|(name, values)| VariableType::Enum(name, values)) + } + + fn matched_close(&self, open: usize) -> Option { + self.partners.get(open).copied().flatten() + } + + fn list_head(&self, limit: usize, frames: &[Frame]) -> Slot { + let Some(frame) = frames.last() else { + return self.start(None); + }; + if frame.kind == FrameKind::Index { + return self.start(Some(VariableType::Number)); + } + let subject = self.list_subject(frame); + if let Some(range) = self.range_ahead(limit) { + let partner = self + .run_end(range + 1) + .and_then(|end| self.type_of_run(range + 1, end, true)); + return self.range_slot(partner, subject); + } + + let mut slot = Slot::new(SlotState::ListElement, (0, 0)); + slot.listed = self.list_listed(frame.open); + slot.options = subject + .as_ref() + .and_then(|s| ValueOption::for_type(s, self.labels)) + .unwrap_or_default(); + slot.options.retain(|o| !slot.listed.contains(&o.value)); + slot.expected = subject.as_ref().map(|s| s.innermost().shallow_clone()); + slot.operand = subject; + slot.auto_open = true; + slot + } + + fn range_after(&self, p: usize, frames: &[Frame]) -> Slot { + let bound = self.left_operand(p); + let subject = frames.last().and_then(|f| self.list_subject(f)); + self.range_slot(bound, subject) + } + + fn range_slot(&self, bound: Option, subject: Option) -> Slot { + let mut slot = Slot::new(SlotState::Range, (0, 0)); + let pick = |t: Option| t.filter(Self::is_ordered); + slot.expected = pick(bound) + .or_else(|| pick(subject)) + .or(Some(VariableType::Number)); + slot + } + + fn range_ahead(&self, limit: usize) -> Option { + let mut depth = 0; + for (i, it) in self.items.iter().enumerate().skip(limit) { + match it.kind { + Kind::Open(_) | Kind::TemplateOpen => depth += 1, + Kind::Close(_) | Kind::TemplateClose => { + if depth == 0 { + return None; + } + depth -= 1; + } + Kind::Op(Operator::Range) if depth == 0 => return Some(i), + Kind::Op(Operator::Comma) if depth == 0 => return None, + _ => {} + } + } + None + } + + fn closure_frame<'f>(&self, frames: &'f [Frame]) -> Option<(&'f Frame, usize)> { + for frame in frames.iter().rev() { + match frame.kind { + FrameKind::Paren => continue, + FrameKind::Call { + name, + method: false, + } => { + return (self.is_closure(name) && frame.commas >= 1).then_some((frame, name)); + } + _ => return None, + } + } + None + } + + fn haystack_element(&self, frame: &Frame) -> Option { + let end = self.run_end(frame.open + 1)?; + match self.type_of_run(frame.open + 1, end, true)? { + VariableType::Array(inner) => Some(inner.as_ref().shallow_clone()), + _ => None, + } + } + + fn element_type(&self, frame: &Frame) -> Option { + let end = self.collection_end(frame)?; + let collection = self.type_of_run(frame.open + 1, end, false)?; + collection.iterator().map(|t| t.as_ref().shallow_clone()) + } + + fn closure_head(&self, frames: &[Frame]) -> Slot { + let Some((frame, name)) = self.closure_frame(frames) else { + return self.start(Some(VariableType::Bool)); + }; + let element = self.element_type(frame); + let mut slot = Slot::new(SlotState::Closure, (0, 0)); + slot.options = element + .as_ref() + .and_then(|e| ValueOption::for_type(e, self.labels)) + .unwrap_or_default(); + slot.expected = element.clone(); + slot.operand = element; + slot.function = Some(self.text(self.items[name].span).to_string()); + slot.argument = Some(1); + slot + } + + fn argument(&self, frames: &[Frame]) -> Slot { + let Some(frame) = frames.last() else { + return self.start(None); + }; + let FrameKind::Call { name, method } = frame.kind else { + return self.start(None); + }; + let name_text = self.text(self.items[name].span); + let index = frame.commas; + + let mut slot = Slot::new(SlotState::Argument, (0, 0)); + slot.function = Some(name_text.to_string()); + slot.argument = Some(index); + + if method { + let Ok(kind) = MethodKind::try_from(name_text) else { + return self.start(None); + }; + slot.expected = MethodRegistry::get_definition(&kind) + .and_then(|d| d.param_type(index as usize + 1)); + if let Some(end) = name + .checked_sub(2) + .filter(|&k| self.items[k].kind.is_operand_end()) + { + let start = self.run_start(end, false); + slot.operand = self.type_of_run(start, end, true); + } + } else { + let Ok(kind) = FunctionKind::try_from(name_text) else { + return self.start(None); + }; + slot.expected = match &kind { + FunctionKind::Closure(_) if index >= 1 => return self.closure_head(frames), + FunctionKind::Closure(_) => Some(VariableType::Array(Rc::new(VariableType::Any))), + FunctionKind::Internal(InternalFunction::Contains) if index == 1 => { + self.haystack_element(frame).or_else(|| { + FunctionRegistry::get_definition(&kind).and_then(|d| d.param_type(1)) + }) + } + _ => FunctionRegistry::get_definition(&kind) + .and_then(|d| d.param_type(index as usize)), + }; + slot.auto_open = name_text == "d" && index == 0; + } + + slot.options = slot + .expected + .as_ref() + .and_then(|e| ValueOption::for_type(e, self.labels)) + .unwrap_or_default(); + slot + } + fn is_field_run(&self, start: usize, end: usize) -> bool { + self.items[start..=end].iter().all(|it| { + matches!( + it.kind, + Kind::Ident | Kind::Ref(_) | Kind::Op(Operator::Dot) + ) + }) + } + + fn after_operand(&self, p: usize, gap: bool, frames: &[Frame]) -> Slot { + let start = self.run_start(p, true); + let before = start.checked_sub(1).map(|k| self.items[k].kind); + let operand = self.type_of_run(start, p, true); + let at_clause = before.is_none_or(Kind::is_clause_boundary); + let literal_clause = self.unary + && frames.is_empty() + && at_clause + && (self.items[start].kind.is_literal_start() || self.is_interval(start)); + let bool_clause = at_clause && operand.as_ref().is_some_and(Self::is_bool); + let date_clause = self.unary + && frames.is_empty() + && at_clause + && matches!(self.subject().unwrap_nullable().0, VariableType::Date) + && operand + .as_ref() + .is_some_and(|t| matches!(t.unwrap_nullable().0, VariableType::Date)); + let after_comparison = matches!(before, Some(Kind::Op(Operator::Comparison(_)))); + + if after_comparison || literal_clause || bool_clause || date_clause { + let mut slot = Slot::new(SlotState::Logical, (0, 0)); + slot.expected = Some(VariableType::Bool); + slot.operators = Operators::LOGICAL.to_vec(); + let nullable = !after_comparison && matches!(operand, Some(VariableType::Nullable(_))); + if nullable { + slot.operators + .extend(Operators::nullable_extras(self.unary)); + } else if bool_clause + && !after_comparison + && !matches!( + before, + Some(Kind::Op(Operator::Logical(LogicalOperator::Not))) + ) + && self.is_field_run(start, p) + { + slot.operators.extend(Operators::EQUALITY); + } + slot.auto_open = gap; + return slot; + } + + let mut slot = Slot::new(SlotState::Operator, (0, 0)); + slot.operators = Operators::for_type(operand.as_ref().unwrap_or(&VariableType::Any), false); + slot.operand = operand; + slot.auto_open = gap && !slot.operators.is_empty(); + slot + } + + fn primary_start(&self, k: usize) -> usize { + let mut depth = 0u32; + let mut i = k; + let mut start = k; + loop { + let kind = self.items[i].kind; + let include = match kind { + Kind::Close(_) | Kind::TemplateClose => { + depth += 1; + true + } + Kind::Open(_) | Kind::TemplateOpen => { + if depth == 0 { + false + } else { + depth -= 1; + true + } + } + _ if depth > 0 => true, + Kind::Str { .. } + | Kind::Ident + | Kind::Ref(_) + | Kind::Number + | Kind::Bool + | Kind::Op(Operator::Dot) => true, + _ => false, + }; + if !include { + break; + } + start = i; + if i == 0 { + break; + } + i -= 1; + } + start + } + + fn run_start(&self, k: usize, extend: bool) -> usize { + let mut start = self.primary_start(k); + if !extend { + return start; + } + while let Some(before) = start.checked_sub(1) { + let Kind::Op(Operator::Arithmetic(arith)) = self.items[before].kind else { + break; + }; + let binary = before + .checked_sub(1) + .is_some_and(|k| self.items[k].kind.is_operand_end()); + if binary { + start = self.primary_start(before - 1); + continue; + } + if matches!( + arith, + ArithmeticOperator::Add | ArithmeticOperator::Subtract + ) { + start = before; + } + break; + } + start + } + + fn run_end(&self, k: usize) -> Option { + let mut depth = 0u32; + let mut end = None; + for (i, it) in self.items.iter().enumerate().skip(k) { + let include = match it.kind { + Kind::Open(_) | Kind::TemplateOpen => { + depth += 1; + true + } + Kind::Close(_) | Kind::TemplateClose => { + if depth == 0 { + false + } else { + depth -= 1; + true + } + } + _ if depth > 0 => true, + Kind::Str { .. } + | Kind::Ident + | Kind::Ref(_) + | Kind::Number + | Kind::Bool + | Kind::Op(Operator::Dot) => true, + Kind::Op(Operator::Arithmetic( + ArithmeticOperator::Add | ArithmeticOperator::Subtract, + )) => end.is_none(), + _ => false, + }; + if !include { + break; + } + end = Some(i); + } + end + } + + fn type_of_run(&self, start: usize, end: usize, with_pointer: bool) -> Option { + let span = (self.items[start].span.0, self.items[end].span.1); + let parsed = self.parsed?; + if let Some(node) = self.table.and_then(|t| t.node_at(span)) { + return Some(parsed.type_of(node)); + } + + let root = parsed.reparse(self.items[start].first, self.items[end].last)?; + let pointer = if with_pointer { + self.pointer_type(start) + } else { + None + }; + let types = + TypesProvider::generate(root, parsed.intellisense_scope(pointer), parsed.strict); + types.get_type(root).map(|t| t.kind.clone()) + } + + fn pointer_type(&self, at: usize) -> Option { + let frames = self.frames(at); + let (frame, _) = self.closure_frame(&frames)?; + self.element_type(frame) + } + + fn list_listed(&self, open: usize) -> Vec { + let mut listed = Vec::new(); + let mut depth = 0u32; + for it in self.items.iter().skip(open + 1) { + match it.kind { + Kind::Open(_) | Kind::TemplateOpen => depth += 1, + Kind::Close(_) | Kind::TemplateClose => { + if depth == 0 { + break; + } + depth -= 1; + } + Kind::Str { open: false, .. } if depth == 0 => { + listed.push(self.text(it.body).to_string()); + } + Kind::Ref(Identifier::Null) if depth == 0 => { + listed.push(ValueOption::NULL.to_string()) + } + _ => {} + } + } + listed + } + + fn unary_listed(&self) -> Vec { + let mut listed = Vec::new(); + let mut depth = 0u32; + let mut list_open = false; + let mut before: Option = None; + for it in self.items.iter() { + match it.kind { + Kind::Open(b) => { + if depth == 0 { + list_open = b == Bracket::LeftSquareBracket + && before.is_none_or(|k| { + k.is_clause_boundary() + || matches!( + k, + Kind::Op(Operator::Comparison( + ComparisonOperator::In | ComparisonOperator::NotIn + )) + ) + }); + } + depth += 1; + } + Kind::TemplateOpen => depth += 1, + Kind::Close(_) | Kind::TemplateClose => { + depth = depth.saturating_sub(1); + if depth == 0 { + list_open = false; + } + } + Kind::Str { open: false, .. } | Kind::Ref(Identifier::Null) => { + let clause_value = depth == 0 + && before.is_none_or(|k| { + k.is_clause_boundary() + || k == Kind::Op(Operator::Comparison(ComparisonOperator::Equal)) + }); + if clause_value || (depth == 1 && list_open) { + listed.push(if it.kind == Kind::Ref(Identifier::Null) { + ValueOption::NULL.to_string() + } else { + self.text(it.body).to_string() + }); + } + } + _ => {} + } + before = Some(it.kind); + } + listed + } +} diff --git a/core/expression/src/slot/literals.rs b/core/expression/src/slot/literals.rs new file mode 100644 index 00000000..2676f6cd --- /dev/null +++ b/core/expression/src/slot/literals.rs @@ -0,0 +1,647 @@ +use std::rc::Rc; +use std::str::FromStr; + +use chrono_tz::Tz; + +use crate::functions::{ClosureFunction, DateMethod, FunctionKind, InternalFunction, MethodKind}; +use crate::lexer::{ + ArithmeticOperator, Bracket, ComparisonOperator, LogicalOperator, Operator, QuotationMark, + Token, TokenKind, +}; +use crate::parser::Node; +use crate::variable::VariableType; +use crate::vm::VmDate; +use crate::Variable; + +use super::classify::Classifier; +use super::{ + DateArg, EnumTable, LabelResolver, LiteralFact, Literals, Parsed, SlotRole, Span, ValueOption, + VariableTypeSlot, +}; + +struct Entry<'a> { + node: &'a Node<'a>, + span: Span, + expected: Option, +} + +pub(crate) struct NodeTable<'a> { + entries: Vec>, +} + +impl<'a> NodeTable<'a> { + pub(crate) fn build(parsed: &Parsed<'a>, unary: bool, expected: Option<&VariableType>) -> Self { + let root_expected = if unary { + Some(VariableType::Bool) + } else { + expected.map(|e| e.shallow_clone()) + }; + + let mut entries = Vec::new(); + let mut stack: Vec<(&'a Node<'a>, Option)> = + vec![(parsed.ast, root_expected)]; + + while let Some((node, expected)) = stack.pop() { + let mut children: Vec<(&'a Node<'a>, Option)> = Vec::new(); + + match node { + Node::Binary { + left, + operator, + right, + } => { + let (left_expected, right_expected) = match operator { + Operator::Comparison(_) => ( + Self::comparison_operand(parsed, right), + Self::comparison_operand(parsed, left), + ), + Operator::Logical(LogicalOperator::And | LogicalOperator::Or) => { + (Some(VariableType::Bool), Some(VariableType::Bool)) + } + Operator::Logical(LogicalOperator::NullishCoalescing) => { + let fallback = expected + .as_ref() + .filter(|e| e.enum_domain().is_some()) + .cloned() + .or_else(|| { + parsed + .type_of(left) + .unwrap_nullable() + .0 + .shallow_clone() + .declared() + }) + .or_else(|| expected.clone()); + (expected.clone(), fallback) + } + _ => (None, None), + }; + children.push((left, left_expected)); + children.push((right, right_expected)); + } + Node::Unary { + node: inner, + operator, + } => { + let inner_expected = match operator { + Operator::Logical(LogicalOperator::Not) => Some(VariableType::Bool), + Operator::Arithmetic( + ArithmeticOperator::Add | ArithmeticOperator::Subtract, + ) => Some(VariableType::Number), + _ => None, + }; + children.push((inner, inner_expected)); + } + Node::Conditional { + condition, + on_true, + on_false, + } => { + children.push((condition, Some(VariableType::Bool))); + children.push((on_true, expected.clone())); + children.push((on_false, expected.clone())); + } + Node::Interval { left, right, .. } => { + children.push((left, expected.clone())); + children.push((right, expected.clone())); + } + Node::Array(items) => { + let item_expected = expected.as_ref().map(|t| t.innermost().shallow_clone()); + for item in items.iter() { + children.push((item, item_expected.clone())); + } + } + Node::Parenthesized(inner) => children.push((inner, expected.clone())), + Node::Closure { body, .. } => children.push((body, expected.clone())), + Node::FunctionCall { kind, arguments } => { + let expectations = + Self::function_expectations(parsed, kind, arguments, expected.as_ref()); + for (i, arg) in arguments.iter().enumerate() { + children.push((arg, expectations.get(i).cloned().flatten())); + } + } + Node::MethodCall { + kind, + this, + arguments, + } => { + children.push((this, None)); + let first = Self::method_expects_date(kind).then_some(VariableType::Date); + for (i, arg) in arguments.iter().enumerate() { + children.push((arg, if i == 0 { first.clone() } else { None })); + } + } + Node::Member { node, property } => { + children.push((node, None)); + children.push((property, None)); + } + Node::Slice { node, from, to } => { + children.push((node, None)); + if let Some(from) = from { + children.push((from, None)); + } + if let Some(to) = to { + children.push((to, None)); + } + } + Node::Object(pairs) => { + for (k, v) in pairs.iter() { + let field = match (expected.as_ref(), k) { + (Some(e), Node::String(key) | Node::Identifier(key)) => { + e.get(key).declared() + } + _ => None, + }; + children.push((k, None)); + children.push((v, field)); + } + } + Node::Assignments { list, output } => { + for (k, v) in list.iter() { + children.push((k, None)); + children.push((v, None)); + } + if let Some(output) = output { + children.push((output, expected.clone())); + } + } + Node::TemplateString(parts) => { + for part in parts.iter() { + children.push((part, None)); + } + } + Node::Error { node: inner, .. } => { + if let Some(inner) = inner { + children.push((inner, expected.clone())); + } + } + Node::Null + | Node::Bool(_) + | Node::Number(_) + | Node::String(_) + | Node::Pointer + | Node::Identifier(_) + | Node::Root => {} + } + + entries.push(Entry { + node, + span: parsed.span_of(node), + expected, + }); + stack.extend(children.into_iter().rev()); + } + + Self { entries } + } + pub(crate) fn node_at(&self, span: Span) -> Option<&'a Node<'a>> { + self.entries + .iter() + .rfind(|e| e.span == span && !matches!(e.node, Node::Error { .. })) + .map(|e| e.node) + } + + fn comparison_operand(parsed: &Parsed, node: &Node) -> Option { + match (node, parsed.type_of(node)) { + (Node::String(_), VariableType::Const(_)) => Some(VariableType::String), + (_, t) => t.declared(), + } + } + + fn method_expects_date(kind: &MethodKind) -> bool { + matches!( + kind, + MethodKind::DateMethod( + DateMethod::IsSame + | DateMethod::IsBefore + | DateMethod::IsAfter + | DateMethod::IsSameOrBefore + | DateMethod::IsSameOrAfter + | DateMethod::Diff + ) + ) + } + + fn function_expectations( + parsed: &Parsed, + kind: &FunctionKind, + arguments: &[&Node], + expected: Option<&VariableType>, + ) -> Vec> { + match kind { + FunctionKind::Closure(cf) => { + let body_expected = match cf { + ClosureFunction::Map | ClosureFunction::FlatMap => None, + _ => Some(VariableType::Bool), + }; + vec![Self::closure_membership(parsed, arguments), body_expected] + } + FunctionKind::Internal(InternalFunction::Date) => vec![Some(VariableType::Date)], + FunctionKind::Internal(InternalFunction::Bool) => { + vec![expected.map(|e| e.shallow_clone())] + } + FunctionKind::Internal( + InternalFunction::Contains + | InternalFunction::StartsWith + | InternalFunction::EndsWith + | InternalFunction::Matches + | InternalFunction::FuzzyMatch, + ) => { + let needle = match arguments.first().map(|a| parsed.type_of(a)) { + Some(haystack @ VariableType::Array(_)) => Some(haystack), + _ => None, + }; + vec![None, needle] + } + _ => Vec::new(), + } + } + + fn closure_membership(parsed: &Parsed, arguments: &[&Node]) -> Option { + let Some(Node::Closure { body, alias }) = arguments.get(1) else { + return None; + }; + let mut node: &Node = body; + while let Node::Parenthesized(inner) = node { + node = inner; + } + let Node::Binary { + left, + operator: Operator::Comparison(ComparisonOperator::In | ComparisonOperator::NotIn), + right, + } = node + else { + return None; + }; + let binds = match left { + Node::Pointer => alias.is_none(), + Node::Identifier(name) => *alias == Some(*name), + _ => false, + }; + if !binds { + return None; + } + let rhs = parsed.type_of(right); + rhs.enum_domain() + .map(|(name, values)| VariableType::Enum(name, values)) + } +} +struct EnumInterner<'l> { + tables: Vec, + labels: Option<&'l LabelResolver>, +} + +impl<'l> EnumInterner<'l> { + fn new(labels: Option<&'l LabelResolver>) -> Self { + Self { + tables: Vec::new(), + labels, + } + } + + fn intern(&mut self, name: Option<&Rc>, values: &[Rc]) -> u32 { + let table = EnumTable { + name: name.map(|n| n.to_string()), + options: ValueOption::enum_list(name.map(|n| n.as_ref()), values, self.labels), + }; + if let Some(index) = self.tables.iter().position(|t| *t == table) { + return index as u32; + } + self.tables.push(table); + (self.tables.len() - 1) as u32 + } + + fn enum_fact( + &mut self, + span: Span, + value: &str, + expected: Option<&VariableType>, + ) -> Option { + let (name, values) = expected?.enum_domain()?; + Some(LiteralFact::Enum { + span, + value: value.to_string(), + name: name.as_ref().map(|n| n.to_string()), + label: ValueOption::label(self.labels, name.as_deref(), value), + valid: values.iter().any(|v| v.as_ref() == value), + enum_index: self.intern(name.as_ref(), &values), + }) + } +} + +enum TokenLiteral<'a> { + Str { + span: Span, + value: &'a str, + }, + Bool { + span: Span, + value: bool, + }, + Date { + span: Span, + first: usize, + last: usize, + }, +} + +impl<'a> TokenLiteral<'a> { + fn span(&self) -> Span { + match self { + TokenLiteral::Str { span, .. } + | TokenLiteral::Bool { span, .. } + | TokenLiteral::Date { span, .. } => *span, + } + } + + fn collect(tokens: &[Token<'a>]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let t = &tokens[i]; + match t.kind { + TokenKind::QuotationMark( + q @ (QuotationMark::SingleQuote | QuotationMark::DoubleQuote), + ) => { + let closed = + tokens + .get(i + 1) + .zip(tokens.get(i + 2)) + .filter(|(body, close)| { + body.kind == TokenKind::Literal + && close.kind == TokenKind::QuotationMark(q) + && close.span.0 < close.span.1 + }); + if let Some((body, close)) = closed { + out.push(Self::Str { + span: (t.span.0, close.span.1), + value: body.value, + }); + } + i += 3; + continue; + } + TokenKind::Boolean(value) => out.push(Self::Bool { + span: t.span, + value, + }), + TokenKind::Literal if t.value == "d" => { + let opens = tokens + .get(i + 1) + .is_some_and(|n| n.kind == TokenKind::Bracket(Bracket::LeftParenthesis)); + let method = i + .checked_sub(1) + .is_some_and(|k| tokens[k].kind == TokenKind::Operator(Operator::Dot)); + if let Some(last) = (opens && !method) + .then(|| Self::call_end(tokens, i + 1)) + .flatten() + .map(|last| { + let bare = last == i + 2; + bare.then(|| Self::today_end(tokens, last)) + .flatten() + .unwrap_or(last) + }) + { + out.push(Self::Date { + span: (t.span.0, tokens[last].span.1), + first: i, + last, + }); + } + } + _ => {} + } + i += 1; + } + out + } + + fn today_end(tokens: &[Token], close: usize) -> Option { + let rest = tokens.get(close + 1..close + 8)?; + let quote = match rest[3].kind { + TokenKind::QuotationMark( + q @ (QuotationMark::SingleQuote | QuotationMark::DoubleQuote), + ) => q, + _ => return None, + }; + let shape = rest[0].kind == TokenKind::Operator(Operator::Dot) + && rest[1].kind == TokenKind::Literal + && rest[1].value == "startOf" + && rest[2].kind == TokenKind::Bracket(Bracket::LeftParenthesis) + && rest[4].kind == TokenKind::Literal + && rest[4].value == "day" + && rest[5].kind == TokenKind::QuotationMark(quote) + && rest[5].span.0 < rest[5].span.1 + && rest[6].kind == TokenKind::Bracket(Bracket::RightParenthesis); + shape.then_some(close + 7) + } + + fn call_end(tokens: &[Token], open: usize) -> Option { + let mut depth = 0u32; + for (i, t) in tokens.iter().enumerate().skip(open) { + match t.kind { + TokenKind::Bracket( + Bracket::LeftParenthesis + | Bracket::LeftSquareBracket + | Bracket::LeftCurlyBracket, + ) => depth += 1, + TokenKind::Bracket(_) => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None + } +} + +impl LiteralFact { + fn for_bool(span: Span, value: bool, expected: Option<&VariableType>) -> Option { + expected + .is_some_and(|e| matches!(e.unwrap_nullable().0, VariableType::Bool)) + .then_some(LiteralFact::Bool { span, value }) + } +} + +impl DateArg { + fn is_today(node: &Node) -> bool { + matches!( + node, + Node::MethodCall { + kind: MethodKind::DateMethod(DateMethod::StartOf), + this: Node::FunctionCall { + kind: FunctionKind::Internal(InternalFunction::Date), + arguments: [], + }, + arguments: [Node::String("day")], + } + ) + } + + fn from_arguments(arguments: &[&Node]) -> Option { + match arguments { + [] => Some(DateArg::Now), + [Node::String(value)] => Some(DateArg::Literal { + value: value.to_string(), + valid: Self::is_valid(value), + tz: None, + }), + [Node::String(value), Node::String(tz)] => Some(DateArg::Literal { + value: value.to_string(), + valid: Tz::from_str(tz).ok().is_some_and(|zone| { + VmDate::new(Variable::String((*value).into()), Some(zone)).is_valid() + }), + tz: Some(tz.to_string()), + }), + [field @ (Node::Identifier(_) | Node::Member { .. })] => Some(DateArg::Field { + path: Self::field_path(field)?.join("."), + }), + _ => None, + } + } + + fn from_tokens(parsed: &Parsed, first: usize, last: usize) -> Option { + match parsed.reparse(first, last)? { + node if Self::is_today(node) => Some(DateArg::Today), + Node::FunctionCall { + kind: FunctionKind::Internal(InternalFunction::Date), + arguments, + } => Self::from_arguments(arguments), + _ => None, + } + } + + fn is_valid(value: &str) -> bool { + VmDate::new(Variable::String(value.into()), None).is_valid() + } + + fn field_path(node: &Node) -> Option> { + match node { + Node::Identifier(name) => Some(vec![name.to_string()]), + Node::Pointer => Some(vec!["#".to_string()]), + Node::Root => Some(vec!["$root".to_string()]), + Node::Member { node, property } => { + let mut base = Self::field_path(node)?; + match property { + Node::String(s) => base.push(s.to_string()), + Node::Root => base.push("$root".to_string()), + Node::Number(n) if n.is_integer() && !n.is_sign_negative() => { + let last = base.pop()?; + base.push(format!("{last}[{n}]")); + } + _ => return None, + } + Some(base) + } + _ => None, + } + } +} + +impl Literals { + pub fn map_spans(mut self, f: impl Fn(Span) -> Span) -> Self { + self.facts = self + .facts + .into_iter() + .map(|fact| fact.map_span(&f)) + .collect(); + self + } + + pub(crate) fn empty(source: &str) -> Self { + Self { + complete: source.trim().is_empty(), + facts: Vec::new(), + enums: Vec::new(), + } + } + + pub(crate) fn collect( + parsed: &Parsed, + table: &NodeTable, + unary: bool, + expected: Option<&VariableType>, + labels: Option<&LabelResolver>, + ) -> Self { + let mut interner = EnumInterner::new(labels); + let mut facts: Vec = Vec::new(); + let open_start = parsed.open_string.map(|(_, start)| start); + let is_open = + |span: Span| open_start == Some(span.0) || span.1 > parsed.source.len() as u32; + + for entry in &table.entries { + let inside_today = facts.iter().any(|f| { + matches!(f, LiteralFact::Date { arg: DateArg::Today, span } + if span.0 <= entry.span.0 && entry.span.1 <= span.1) + }); + let fact = match entry.node { + Node::String(value) if !is_open(entry.span) && !inside_today => { + interner.enum_fact(entry.span, value, entry.expected.as_ref()) + } + Node::Bool(value) => { + LiteralFact::for_bool(entry.span, *value, entry.expected.as_ref()) + } + node if DateArg::is_today(node) => Some(LiteralFact::Date { + span: entry.span, + arg: DateArg::Today, + }), + Node::FunctionCall { + kind: FunctionKind::Internal(InternalFunction::Date), + arguments, + } if !inside_today => { + let open_arg = arguments.first().is_some_and(|a| { + matches!(a, Node::String(_)) && is_open(parsed.span_of(a)) + }); + if open_arg { + None + } else { + DateArg::from_arguments(arguments).map(|arg| LiteralFact::Date { + span: entry.span, + arg, + }) + } + } + _ => None, + }; + facts.extend(fact); + } + + if !parsed.complete || parsed.ast.has_error() { + let operands = + Classifier::new(parsed, table, unary, SlotRole::Condition, expected, labels); + for literal in TokenLiteral::collect(parsed.tokens) { + let span = literal.span(); + let covered = facts.iter().any(|f| { + let s = f.span(); + s.0 <= span.0 && span.1 <= s.1 + }); + if covered { + continue; + } + let fact = match literal { + TokenLiteral::Str { span, value } => { + interner.enum_fact(span, value, operands.expected_at(span.0).as_ref()) + } + TokenLiteral::Bool { span, value } => { + LiteralFact::for_bool(span, value, operands.expected_at(span.0).as_ref()) + } + TokenLiteral::Date { span, first, last } => { + DateArg::from_tokens(parsed, first, last) + .map(|arg| LiteralFact::Date { span, arg }) + } + }; + facts.extend(fact); + } + } + + facts.sort_by_key(|f| f.span()); + Self { + complete: parsed.is_complete(), + facts, + enums: interner.tables, + } + } +} diff --git a/core/expression/src/slot/mod.rs b/core/expression/src/slot/mod.rs new file mode 100644 index 00000000..5f332cc9 --- /dev/null +++ b/core/expression/src/slot/mod.rs @@ -0,0 +1,500 @@ +use std::rc::Rc; + +use bumpalo::Bump; +use serde::Serialize; + +use crate::intellisense::scope::IntelliSenseScope; +use crate::intellisense::type_provider::TypesProvider; +use crate::intellisense::{AstMetadata, IntelliSense}; +use crate::lexer::{Lexer, QuotationMark, Token}; +use crate::parser::{Node, Parser}; +use crate::variable::VariableType; + +mod classify; +mod literals; +mod operators; + +use classify::Classifier; +pub(crate) use literals::NodeTable; + +pub type LabelResolver = Rc Option>; +pub type Span = (u32, u32); + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SlotRole { + Unary, + Condition, + Value, + Path, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ValueOption { + pub value: String, + pub label: String, + pub source: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SlotState { + Start, + UnaryStart, + Value, + ListElement, + Range, + Operator, + Logical, + Argument, + Member, + Closure, + InString, + Path, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Local { + pub name: String, + #[serde(rename = "type")] + pub kind: VariableType, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Slot { + pub state: SlotState, + pub expected: Option, + pub operand: Option, + pub options: Vec, + pub operators: Vec<&'static str>, + pub function: Option, + pub argument: Option, + pub replace_span: Span, + pub in_string: Option, + pub listed: Vec, + pub auto_open: bool, + pub locals: Vec, + #[serde(skip)] + pub can_chain: bool, + #[serde(skip)] + pub suppress_completions: bool, +} + +impl Slot { + pub fn wanted_scalar(&self) -> Option<&VariableType> { + if !matches!( + self.state, + SlotState::Value + | SlotState::UnaryStart + | SlotState::ListElement + | SlotState::Range + | SlotState::Argument + ) { + return None; + } + let (t, _) = self.expected.as_ref()?.unwrap_nullable(); + ScalarClass::of(t) + .filter(|class| *class != ScalarClass::Bool) + .map(|_| t) + } + + pub(crate) fn new(state: SlotState, replace_span: Span) -> Self { + Self { + state, + expected: None, + operand: None, + options: Vec::new(), + operators: Vec::new(), + function: None, + argument: None, + replace_span, + in_string: None, + listed: Vec::new(), + auto_open: false, + locals: Vec::new(), + can_chain: false, + suppress_completions: false, + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum LiteralFact { + Enum { + span: Span, + value: String, + name: Option, + label: String, + valid: bool, + enum_index: u32, + }, + Date { + span: Span, + arg: DateArg, + }, + Bool { + span: Span, + value: bool, + }, +} + +impl LiteralFact { + pub fn span(&self) -> Span { + match self { + LiteralFact::Enum { span, .. } + | LiteralFact::Date { span, .. } + | LiteralFact::Bool { span, .. } => *span, + } + } + + pub fn map_span(mut self, f: impl Fn(Span) -> Span) -> Self { + match &mut self { + LiteralFact::Enum { span, .. } + | LiteralFact::Date { span, .. } + | LiteralFact::Bool { span, .. } => *span = f(*span), + } + self + } +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum DateArg { + Now, + Today, + Literal { + value: String, + valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + tz: Option, + }, + Field { + path: String, + }, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EnumTable { + pub name: Option, + pub options: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Literals { + pub complete: bool, + #[serde(rename = "literals")] + pub facts: Vec, + pub enums: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SlotResult { + pub slot: Slot, + pub literals: Literals, +} + +pub(crate) type EnumDomain = (Option>, Vec>); + +pub trait VariableTypeSlot { + fn innermost(&self) -> &VariableType; + fn enum_domain(&self) -> Option; + fn declared(self) -> Option; +} + +impl VariableTypeSlot for VariableType { + fn innermost(&self) -> &VariableType { + match self { + VariableType::Array(inner) | VariableType::Nullable(inner) => inner.innermost(), + other => other, + } + } + + fn enum_domain(&self) -> Option { + match self.innermost() { + VariableType::Enum(name, values) => Some((name.clone(), values.clone())), + VariableType::Const(value) => Some((None, vec![value.clone()])), + _ => None, + } + } + + fn declared(self) -> Option { + match self { + VariableType::Any => None, + other => Some(other), + } + } +} + +impl ValueOption { + pub(crate) const NULL: &'static str = "null"; + + pub fn for_type(t: &VariableType, labels: Option<&LabelResolver>) -> Option> { + let (name, values) = t.enum_domain()?; + Some(Self::enum_list(name.as_deref(), &values, labels)) + } + + pub(crate) fn enum_list( + name: Option<&str>, + values: &[Rc], + labels: Option<&LabelResolver>, + ) -> Vec { + values + .iter() + .map(|v| Self { + value: v.to_string(), + label: Self::label(labels, name, v), + source: Self::encode(v), + }) + .collect() + } + + pub(crate) fn label(labels: Option<&LabelResolver>, name: Option<&str>, value: &str) -> String { + labels + .zip(name) + .and_then(|(resolve, n)| resolve(n, value)) + .filter(|l| !l.is_empty()) + .unwrap_or_else(|| value.to_string()) + } + + pub(crate) fn null() -> Self { + Self { + value: Self::NULL.to_string(), + label: Self::NULL.to_string(), + source: Some(Self::NULL.to_string()), + } + } + + pub(crate) fn is_null(&self) -> bool { + self.value == Self::NULL && self.source.as_deref() == Some(Self::NULL) + } + + fn encode(value: &str) -> Option { + if !value.contains('"') { + Some(format!("\"{value}\"")) + } else if !value.contains('\'') { + Some(format!("'{value}'")) + } else { + None + } + } +} + +pub(crate) struct Parsed<'a> { + pub arena: &'a Bump, + pub source: &'a str, + pub tokens: &'a [Token<'a>], + pub open_string: Option<(QuotationMark, u32)>, + pub ast: &'a Node<'a>, + pub complete: bool, + pub metadata: AstMetadata, + pub types: TypesProvider, + pub scope: VariableType, + pub strict: bool, +} + +impl<'a> Parsed<'a> { + pub(crate) fn span_of(&self, node: &Node) -> Span { + let addr = node as *const Node as usize; + node.span() + .or_else(|| self.metadata.get(&addr).map(|m| m.span)) + .unwrap_or_default() + } + + pub(crate) fn type_of(&self, node: &Node) -> VariableType { + self.types + .get_type(node) + .map(|t| t.kind.clone()) + .unwrap_or(VariableType::Any) + } + + pub(crate) fn intellisense_scope(&self, pointer: Option) -> IntelliSenseScope { + let mut scope = IntelliSenseScope { + root_data: self.scope.shallow_clone(), + current_data: self.scope.shallow_clone(), + ..Default::default() + }; + match pointer { + Some(p) => { + scope.aliases.insert(Rc::from("#"), p.shallow_clone()); + scope.pointer_data = p; + } + None => scope.pointer_data = self.scope.shallow_clone(), + } + scope + } +} + +impl<'a> Parsed<'a> { + fn new( + arena: &'a Bump, + lexer: &mut Lexer, + strict: bool, + source: &'a str, + unary: bool, + scope: &VariableType, + ) -> Option { + let lenient = lexer.tokenize_lenient(arena, source).ok()?; + let tokens: &'a [Token<'a>] = lenient.tokens.into_bump_slice(); + let parser = Parser::try_new(tokens, arena).ok()?; + let result = if unary { + parser.unary().with_metadata().parse() + } else { + parser.standard().with_metadata().parse() + }; + let is_scope = IntelliSenseScope { + pointer_data: scope.shallow_clone(), + root_data: scope.shallow_clone(), + current_data: scope.shallow_clone(), + ..Default::default() + }; + + Some(Self { + arena, + source, + tokens, + open_string: lenient.open_string, + ast: result.root, + complete: result.is_complete, + metadata: result.metadata.unwrap_or_default(), + types: TypesProvider::generate(result.root, is_scope, strict), + scope: scope.shallow_clone(), + strict, + }) + } + + fn clamp(source: &str, pos: u32) -> u32 { + let mut pos = (pos as usize).min(source.len()); + while pos > 0 && !source.is_char_boundary(pos) { + pos -= 1; + } + pos as u32 + } + + pub(crate) fn reparse(&self, first: usize, last: usize) -> Option<&'a Node<'a>> { + let result = Parser::try_new(&self.tokens[first..=last], self.arena) + .ok()? + .standard() + .parse(); + (result.is_complete && !result.root.has_error()).then_some(result.root) + } + + fn is_complete(&self) -> bool { + self.complete && !self.ast.has_error() && self.open_string.is_none() + } +} + +impl IntelliSense { + pub fn slot( + &mut self, + source: &str, + pos: u32, + unary: bool, + role: SlotRole, + scope: &VariableType, + expected: Option<&VariableType>, + ) -> SlotResult { + self.arena.reset(); + let pos = Parsed::clamp(source, pos); + let labels = self.labels.as_ref(); + let Some(parsed) = Parsed::new( + &self.arena, + &mut self.lexer, + self.strict, + source, + unary, + scope, + ) else { + return SlotResult { + slot: Classifier::fallback(source, pos, unary, role, scope, expected, labels), + literals: Literals::empty(source), + }; + }; + let table = NodeTable::build(&parsed, unary, expected); + SlotResult { + slot: Classifier::classify(&parsed, &table, pos, unary, role, expected, labels), + literals: Literals::collect(&parsed, &table, unary, expected, labels), + } + } + + pub fn closure_locals( + &mut self, + source: &str, + pos: u32, + scope: &VariableType, + ) -> Vec<(Rc, VariableType)> { + self.arena.reset(); + let pos = Parsed::clamp(source, pos); + let Some(parsed) = Parsed::new( + &self.arena, + &mut self.lexer, + self.strict, + source, + false, + scope, + ) else { + return Vec::new(); + }; + let table = NodeTable::build(&parsed, false, None); + Classifier::closure_locals(&parsed, &table, pos) + } + + pub fn literals( + &mut self, + source: &str, + unary: bool, + scope: &VariableType, + expected: Option<&VariableType>, + ) -> Literals { + self.arena.reset(); + let labels = self.labels.as_ref(); + let Some(parsed) = Parsed::new( + &self.arena, + &mut self.lexer, + self.strict, + source, + unary, + scope, + ) else { + return Literals::empty(source); + }; + let table = NodeTable::build(&parsed, unary, expected); + Literals::collect(&parsed, &table, unary, expected, labels) + } +} + +#[derive(PartialEq, Eq, Clone, Copy)] +pub(crate) enum ScalarClass { + Bool, + Number, + String, + Date, +} + +impl ScalarClass { + fn of(t: &VariableType) -> Option { + match t { + VariableType::Bool => Some(Self::Bool), + VariableType::Number => Some(Self::Number), + VariableType::String | VariableType::Const(_) | VariableType::Enum(..) => { + Some(Self::String) + } + VariableType::Date => Some(Self::Date), + _ => None, + } + } +} diff --git a/core/expression/src/slot/operators.rs b/core/expression/src/slot/operators.rs new file mode 100644 index 00000000..788c7230 --- /dev/null +++ b/core/expression/src/slot/operators.rs @@ -0,0 +1,61 @@ +use crate::variable::VariableType; + +pub(crate) struct Operators; + +impl Operators { + const ORDERED: &[&str] = &["==", "!=", "<", "<=", ">", ">=", "in", "not in"]; + const ORDERED_UNARY: &[&str] = &[">", ">=", "<", "<=", "==", "!=", "in", "not in"]; + const TEXT: &[&str] = &["==", "!=", "in", "not in"]; + const TEXT_UNARY: &[&str] = &["!=", "in", "not in"]; + pub(crate) const EQUALITY: &[&str] = &["==", "!="]; + pub(crate) const LOGICAL: &[&str] = &["and", "or"]; + + pub(crate) fn nullable_extras(unary: bool) -> &'static [&'static str] { + if unary { + Self::EQUALITY + } else { + &["==", "!=", "??"] + } + } + + pub(crate) fn for_type(t: &VariableType, unary: bool) -> Vec<&'static str> { + if let VariableType::Nullable(inner) = t { + let mut list = Self::for_type(inner, unary); + let mut front = 0; + for op in Self::nullable_extras(unary) { + if list.contains(op) { + continue; + } + if *op == "??" { + list.push(op); + } else { + list.insert(front, op); + front += 1; + } + } + return list; + } + let list: &[&str] = match (t, unary) { + (VariableType::Number | VariableType::Date | VariableType::Any, true) => { + Self::ORDERED_UNARY + } + (VariableType::Number | VariableType::Date | VariableType::Any, false) => Self::ORDERED, + (VariableType::Enum(..) | VariableType::Const(_) | VariableType::String, true) => { + Self::TEXT_UNARY + } + (VariableType::Enum(..) | VariableType::Const(_) | VariableType::String, false) => { + Self::TEXT + } + (VariableType::Array(_), _) => &[], + ( + VariableType::Bool + | VariableType::Null + | VariableType::Object(_) + | VariableType::Interval + | VariableType::Nullable(_), + _, + ) => Self::EQUALITY, + }; + list.to_vec() + } +} diff --git a/core/expression/tests/analysis.rs b/core/expression/tests/analysis.rs index e458dab8..0fcb5cb9 100644 --- a/core/expression/tests/analysis.rs +++ b/core/expression/tests/analysis.rs @@ -4,6 +4,7 @@ use zen_expression::intellisense::dependency::{ReadDependency, Reference}; use zen_expression::intellisense::diagnostic::{DiagnosticSource, Severity}; use zen_expression::intellisense::{ExpressionAnalysis, IntelliSense}; +use zen_expression::slot::SlotRole; use zen_expression::variable::VariableType; #[derive(Debug, Deserialize)] @@ -60,6 +61,9 @@ struct ModeExpectations { struct ExpectedDiagnostic { source: String, severity: String, + code: Option, + #[serde(default)] + args: std::collections::BTreeMap, } fn parse_data_type(test: &TestCase) -> VariableType { @@ -310,15 +314,23 @@ fn check_diagnostics( let expected_source = parse_diagnostic_source(&expected.source); let expected_severity = parse_severity(&expected.severity); - let matching = result - .diagnostics - .iter() - .any(|d| d.source == expected_source && d.severity == expected_severity); + let matching = result.diagnostics.iter().any(|d| { + d.source == expected_source + && d.severity == expected_severity + && expected + .code + .as_deref() + .is_none_or(|code| d.code == Some(code)) + && expected + .args + .iter() + .all(|(k, v)| d.args.get(k.as_str()) == Some(v)) + }); assert!( matching, - "[{file_name}:{}:{mode}] Diagnostic #{i} not found: expected source={}, severity={}.\n Expression: {}\n Got diagnostics: {:?}", - test.name, expected.source, expected.severity, test.expression, result.diagnostics + "[{file_name}:{}:{mode}] Diagnostic #{i} not found: expected source={}, severity={}, code={:?}, args={:?}.\n Expression: {}\n Got diagnostics: {:?}", + test.name, expected.source, expected.severity, expected.code, expected.args, test.expression, result.diagnostics ); } } @@ -440,6 +452,9 @@ struct InspectTestCase { input: Option, label: Option, kind: Option, + detail: Option, + #[serde(default)] + unary: bool, } fn run_inspect_test_file(file_name: &str, toml_data: &str) { @@ -459,9 +474,14 @@ fn run_inspect_test_file(file_name: &str, toml_data: &str) { None => VariableType::Any, }; - let result = is.inspect(&test.expression, test.pos, &data_type); + let role = if test.unary { + SlotRole::Unary + } else { + SlotRole::Value + }; + let result = is.inspect(&test.expression, test.pos, test.unary, role, &data_type); - if test.label.is_some() || test.kind.is_some() { + if test.label.is_some() || test.kind.is_some() || test.detail.is_some() { let result = result.unwrap_or_else(|| { panic!( "[{file_name}:{}] Expected inspect result, got None.\n Expression: {:?} @ pos {}", @@ -491,6 +511,20 @@ fn run_inspect_test_file(file_name: &str, toml_data: &str) { test.name, test.expression, test.pos ); } + + if let Some(expected_detail) = &test.detail { + assert!( + result + .detail + .as_deref() + .is_some_and(|detail| detail.starts_with(expected_detail.as_str())), + "[{file_name}:{}] detail mismatch: {:?}.\n Expression: {:?} @ pos {}", + test.name, + result.detail, + test.expression, + test.pos + ); + } } } } diff --git a/core/expression/tests/data/analysis/closures.toml b/core/expression/tests/data/analysis/closures.toml index 0a52a3f9..ee4367e9 100644 --- a/core/expression/tests/data/analysis/closures.toml +++ b/core/expression/tests/data/analysis/closures.toml @@ -175,7 +175,9 @@ name = "callback ref member access in closure" expression = "map(items, #.price)" input = '{"items": [{"price": 10}]}' reads = [ - { type = "iteration", collection = ["items"], reads = [] }, + { type = "iteration", collection = ["items"], reads = [ + { type = "direct", path = ["items", "price"], via_index = true }, + ] }, ] [test.loose] diff --git a/core/expression/tests/data/analysis/diagnostics.toml b/core/expression/tests/data/analysis/diagnostics.toml index f55ce2bf..083da637 100644 --- a/core/expression/tests/data/analysis/diagnostics.toml +++ b/core/expression/tests/data/analysis/diagnostics.toml @@ -313,3 +313,55 @@ return_type = '{"Array":"Number"}' [test.strict] return_type = '{"Array":"Number"}' + +# Diagnostic codes + +[[test]] +name = "code: unknown member" +expression = "customer.nme" +input = '{"customer": {"name": "x"}}' + +[test.strict] +diagnostics = [ + { source = "type_check", severity = "error", code = "type.unknown-member", args = { name = "nme" } }, +] + +[[test]] +name = "code: invalid enum member" +expression = "status == 'pending'" +input = '{"Object":{"status":{"Enum":["status",["open","closed"]]}}}' + +[test.loose] +diagnostics = [ + { source = "type_check", severity = "error", code = "type.invalid-enum-member", args = { value = "pending", enum = "status" } }, +] + +[[test]] +name = "code: function argument mismatch" +expression = "upper(5)" + +[test.loose] +diagnostics = [ + { source = "type_check", severity = "error", code = "type.mismatch", args = { got = "number" } }, +] + +[[test]] +name = "code: expected property" +expression = "customer." +input = '{"customer": {"name": "x"}}' + +[test.loose] +diagnostics = [ + { source = "parser", severity = "error", code = "expr.expected-property" }, +] + + +[[test]] +name = "code: missing value after operator" +expression = "a *" +input = '{"a": 1}' + +[test.loose] +diagnostics = [ + { source = "parser", severity = "error", code = "expr.missing-value", args = { operator = "*" } }, +] diff --git a/core/expression/tests/data/analysis/enums.toml b/core/expression/tests/data/analysis/enums.toml index ab2a2e00..87cbc7fd 100644 --- a/core/expression/tests/data/analysis/enums.toml +++ b/core/expression/tests/data/analysis/enums.toml @@ -48,6 +48,16 @@ return_type = '{"Enum":[null,["high","low"]]}' [test.strict] return_type = '{"Enum":[null,["high","low"]]}' +[[test]] +name = "ternary over two string literals produces an anonymous enum" +expression = "true ? 'hello' : 'world'" + +[test.loose] +return_type = '{"Enum":[null,["hello","world"]]}' + +[test.strict] +return_type = '{"Enum":[null,["hello","world"]]}' + [[test]] name = "nested ternary produces enum" expression = "x > 100 ? 'high' : x > 50 ? 'mid' : 'low'" diff --git a/core/expression/tests/data/analysis/functions.toml b/core/expression/tests/data/analysis/functions.toml index 60043c8d..7236395a 100644 --- a/core/expression/tests/data/analysis/functions.toml +++ b/core/expression/tests/data/analysis/functions.toml @@ -289,3 +289,22 @@ return_type = '"Bool"' [test.strict] return_type = '"Bool"' + +[[test]] +name = "failed overload keeps its return type" +expression = "len(tags)" +input = '{"Object": {"tags": {"Nullable": {"Array": "String"}}}}' + +[test.loose] +return_type = '"Number"' +diagnostics = [ + { source = "type_check", severity = "error" }, + { source = "type_check", severity = "error" }, +] + +[test.strict] +return_type = '"Number"' +diagnostics = [ + { source = "type_check", severity = "error" }, + { source = "type_check", severity = "error" }, +] diff --git a/core/expression/tests/data/analysis/inspect.toml b/core/expression/tests/data/analysis/inspect.toml index 68f330ea..57cca85c 100644 --- a/core/expression/tests/data/analysis/inspect.toml +++ b/core/expression/tests/data/analysis/inspect.toml @@ -52,3 +52,51 @@ pos = 4 input = '{"a": 1, "b": 2}' label = "b" kind = '"Number"' + +[[test]] +name = "member - property resolves to its type" +expression = "customer.firstName" +pos = 12 +input = '{"customer": {"firstName": "John"}}' +label = "firstName" +kind = '"String"' + +[[test]] +name = "member - incomplete expression" +expression = "customer.age >" +pos = 10 +input = '{"customer": {"age": 30}}' +label = "age" +kind = '"Number"' + +[[test]] +name = "function - signature and return type" +expression = "upper(name)" +pos = 1 +input = '{"name": "x"}' +label = "upper" +kind = '"String"' +detail = "(str: string) -> string" + +[[test]] +name = "method - signature" +expression = "d('2024-01-01').year()" +pos = 17 +label = "year" +detail = "() -> number" + +[[test]] +name = "string body is not a name" +expression = "customer == 'customer'" +pos = 15 +input = '{"customer": "x"}' +label = "'customer'" + +[[test]] +name = "unary - context reference" +expression = "$ > 10" +pos = 0 +input = '{"$": 5}' +unary = true +label = "$" +kind = '"Number"' diff --git a/core/expression/tests/data/analysis/nullable.toml b/core/expression/tests/data/analysis/nullable.toml index deb1e261..e80d10ee 100644 --- a/core/expression/tests/data/analysis/nullable.toml +++ b/core/expression/tests/data/analysis/nullable.toml @@ -493,6 +493,17 @@ return_type = '"Number"' [test.strict] return_type = '"Number"' +[[test]] +name = "coalesce: Nullable(Const) ?? const merges into an enum" +expression = "kind ?? 'world'" +input = '{"Object": {"kind": {"Nullable": {"Const": "hello"}}}}' + +[test.loose] +return_type = '{"Enum":[null,["hello","world"]]}' + +[test.strict] +return_type = '{"Enum":[null,["hello","world"]]}' + [[test]] name = "coalesce: non-nullable passes through" expression = "name ?? 'fallback'" diff --git a/core/expression/tests/data/analysis/read_spans.toml b/core/expression/tests/data/analysis/read_spans.toml index 65a2c27d..fedbdca3 100644 --- a/core/expression/tests/data/analysis/read_spans.toml +++ b/core/expression/tests/data/analysis/read_spans.toml @@ -82,6 +82,7 @@ expression = "filter(order.items, $.active)" input = '{"order": {"items": [{"active": true}]}}' reads_with_spans = [ { type = "iteration", collection = ["order", "items"], span = [7, 18], reads = [ + { type = "direct", path = ["order", "items", "active"], span = [22, 28], via_index = true }, ] }, ] diff --git a/core/expression/tests/data/slots.toml b/core/expression/tests/data/slots.toml new file mode 100644 index 00000000..9f858d7d --- /dev/null +++ b/core/expression/tests/data/slots.toml @@ -0,0 +1,588 @@ +# Start and values + +[[test]] +expression = "|" +slot = { state = "start", type = "bool", span = "0..0" } + +[[test]] +expression = "ag|" +slot = { state = "start", type = "bool", span = "0..2", auto_open = false } + +[[test]] +expression = "|" +role = "value" +expected = "status" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "0..0" } + +[[test]] +expression = "age == |" +slot = { state = "value", type = "number", options = [], span = "7..7" } + +[[test]] +expression = "since < |" +slot = { state = "value", type = "date", options = [], span = "8..8" } + +[[test]] +expression = "status == |" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "10..10" } + +[[test]] +expression = "tier == |" +slot = { state = "value", type = "\"gold\" | \"silver\"", options = ["gold", "silver"], span = "8..8" } + +[[test]] +expression = "status in |" +slot = { state = "value", type = "status[]", options = ["open=Open case", "closed=Closed"], span = "10..10" } + +[[test]] +expression = "age > -|" +slot = { state = "value", type = "number", options = [], span = "7..7" } + +[[test]] +expression = "(age > 1 and (status == |" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "24..24" } + +# Inside strings + +[[test]] +expression = "status == \"op|" +slot = { state = "inString", type = "status", options = ["open=Open case"], span = "10..13" } + +[[test]] +expression = "status == \"clo|sed\"" +slot = { state = "inString", type = "status", options = ["closed=Closed"], span = "10..18" } + +[[test]] +expression = "status in ['op|" +slot = { state = "inString", type = "status", options = ["open=Open case"], span = "11..14" } + +[[test]] +expression = "mood == \"😀|" +slot = { state = "inString", type = "mood", options = ["happy=😀 Happy"], span = "8..13" } + +[[test]] +expression = "startsWith(name, \"|" +slot = { state = "inString", type = "string", options = [], span = "17..18", auto_open = false } + +[[test]] +expression = "`Hel|lo`" +role = "value" +expected = "string" +slot = { state = "inString", type = "string", options = [], span = "0..7" } + +# Lists and ranges + +[[test]] +expression = "status in [|" +slot = { state = "listElement", type = "status", options = ["open=Open case", "closed=Closed"], span = "11..11" } + +[[test]] +expression = "status in [\"open\", |" +slot = { state = "listElement", type = "status", options = ["closed=Closed"], span = "19..19" } + +[[test]] +expression = "status in [\"open\", \"closed\", |" +slot = { state = "listElement", type = "status", options = [], span = "29..29" } + +[[test]] +expression = "age in [1..|" +slot = { state = "range", type = "number", span = "11..11", auto_open = false } + +[[test]] +expression = "since in [d()..|" +slot = { state = "range", type = "date", span = "15..15", auto_open = false } + +[[test]] +expression = "items[|" +slot = { state = "start", type = "number", span = "6..6" } + +[[test]] +expression = "some([|], # in statuses)" +slot = { state = "listElement", type = "status", options = ["open=Open case", "closed=Closed"], span = "6..6" } + +[[test]] +expression = "some([\"open\", |], # in statuses)" +slot = { state = "listElement", type = "status", options = ["closed=Closed"], span = "14..14" } + +# Operators after an operand + +[[test]] +expression = "age |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in"], span = "4..4" } + +[[test]] +expression = "name |" +slot = { state = "operator", operators = ["==", "!=", "in", "not in"], span = "5..5" } + +[[test]] +expression = "tags |" +slot = { state = "operator", operators = [], span = "5..5" } + +[[test]] +expression = "amount |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in", "??"], span = "7..7" } + +[[test]] +expression = "d() |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in"], span = "4..4" } + +[[test]] +expression = "len(x) |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in"], span = "7..7" } + +[[test]] +expression = "age >|" +slot = { state = "operator", operators = [">", ">="], span = "4..5" } + +[[test]] +expression = "age <=|" +slot = { state = "operator", operators = ["<="], span = "4..6" } + +[[test]] +expression = "age <= |" +slot = { state = "value", type = "number", options = [], span = "7..7" } + +[[test]] +expression = "status ==|" +slot = { state = "operator", operators = ["=="], span = "7..9" } + +[[test]] +expression = "claim.amount =|" +slot = { state = "operator", operators = ["=="], span = "13..14" } + +# Logical after a condition + +[[test]] +expression = "active |" +slot = { state = "logical", type = "bool", operators = ["and", "or", "==", "!="], span = "7..7" } + +[[test]] +expression = "not active |" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "11..11" } + +[[test]] +expression = "age > 1 |" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "8..8" } + +[[test]] +expression = "status == \"closed\"|" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "18..18", auto_open = false } + +[[test]] +expression = "age > 1 an|" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "8..10" } + +[[test]] +expression = "age > 1 and age |" +slot = { state = "operator", operators = ["==", "!=", "<", "<=", ">", ">=", "in", "not in"], span = "16..16" } + +[[test]] +expression = "since > d().startOf(\"day\") |" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "27..27" } + +# Connectives, ternary, coalesce, arithmetic + +[[test]] +expression = "age > 1 and |" +slot = { state = "start", type = "bool", span = "12..12" } + +[[test]] +expression = "not |" +slot = { state = "start", type = "bool", span = "4..4" } + +[[test]] +expression = "age > 18 ? |" +role = "value" +expected = "status" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "11..11" } + +[[test]] +expression = "amount ?? |" +role = "value" +expected = "number" +slot = { state = "value", type = "number", options = [], span = "10..10" } + +[[test]] +expression = "age + |" +role = "value" +expected = "number" +slot = { state = "start", type = "number", span = "6..6" } + +[[test]] +expression = "{ a: |" +role = "value" +expected = "number" +slot = { state = "start", span = "5..5" } + +# Member access + +[[test]] +expression = "customer.|" +slot = { state = "member", span = "9..9", auto_open = false } + +[[test]] +expression = "customer.na|" +slot = { state = "member", span = "9..11", auto_open = false } + +[[test]] +expression = "d().|" +slot = { state = "member", span = "4..4", auto_open = false } + +[[test]] +expression = "some(items, #.|" +slot = { state = "member", span = "14..14", auto_open = false } + +# Function arguments and closures + +[[test]] +expression = "d(|" +slot = { state = "argument", type = "any", span = "2..2" } + +[[test]] +expression = "d(\"2024-01-01\", |" +slot = { state = "argument", type = "string", span = "16..16" } + +[[test]] +expression = "startsWith(name, |" +slot = { state = "argument", type = "string", span = "17..17" } + +[[test]] +expression = "since.isAfter(|" +slot = { state = "argument", type = "date", span = "14..14" } + +[[test]] +expression = "some(|" +slot = { state = "argument", type = "any[]", span = "5..5" } + +[[test]] +expression = "some(items, |" +slot = { state = "closure", type = "object", options = [], span = "12..12" } + +[[test]] +expression = "some(statuses, |" +slot = { state = "closure", type = "status", options = ["open=Open case", "closed=Closed"], span = "15..15" } + +[[test]] +expression = "some(items, #.status == |" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "24..24" } + +[[test]] +expression = "some(statuses as s, s == |" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "25..25" } + +[[test]] +expression = "some(items as x, some(statuses as s, |" +slot = { state = "closure", type = "status", options = ["open=Open case", "closed=Closed"], span = "37..37" } + +[[test]] +expression = "some(items as x, x|" +slot = { state = "closure", type = "object", options = [], span = "17..18", auto_open = false } + +# Unary cells + +[[test]] +expression = "|" +subject = "status" +slot = { state = "unaryStart", type = "status", options = ["open=Open case", "closed=Closed"], operators = ["!=", "in", "not in"], span = "0..0" } + +[[test]] +expression = "\"op|" +subject = "status" +slot = { state = "inString", type = "status", options = ["open=Open case"], span = "0..3" } + +[[test]] +expression = "\"open\", |" +subject = "status" +slot = { state = "unaryStart", type = "status", options = ["closed=Closed"], operators = ["!=", "in", "not in"], span = "8..8" } + +[[test]] +expression = "\"open\" or |" +subject = "status" +slot = { state = "unaryStart", type = "status", options = ["closed=Closed"], operators = ["!=", "in", "not in"], span = "10..10" } + +[[test]] +expression = "\"open\", \"closed\", |" +subject = "status" +slot = { state = "unaryStart", type = "status", options = [], operators = ["!=", "in", "not in"], span = "18..18" } + +[[test]] +expression = "!= |" +subject = "status" +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "3..3" } + +[[test]] +expression = "[\"open\", |" +subject = "status" +slot = { state = "listElement", type = "status", options = ["closed=Closed"], span = "9..9" } + +[[test]] +expression = "not in [|" +subject = "status" +slot = { state = "listElement", type = "status", options = ["open=Open case", "closed=Closed"], span = "8..8" } + +[[test]] +expression = "$ |" +subject = "status" +slot = { state = "operator", operators = ["==", "!=", "in", "not in"], span = "2..2" } + +[[test]] +expression = "|" +subject = "number" +slot = { state = "unaryStart", type = "number", options = [], operators = [">", ">=", "<", "<=", "==", "!=", "in", "not in"], span = "0..0" } + +[[test]] +expression = "> 5 |" +subject = "number" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "4..4" } + +[[test]] +expression = "[1..|" +subject = "number" +slot = { state = "range", type = "number", span = "4..4", auto_open = false } + +[[test]] +expression = "<|" +subject = "number" +slot = { state = "operator", operators = ["<", "<="], span = "0..1" } + +[[test]] +expression = ">= 1 and >| 2" +subject = "number" +slot = { state = "operator", operators = [">", ">="], span = "9..10" } + +[[test]] +expression = "|" +subject = "date" +slot = { state = "unaryStart", type = "date", options = [], operators = [">", ">=", "<", "<=", "==", "!=", "in", "not in"], span = "0..0" } + +[[test]] +expression = "|" +subject = "bool" +slot = { state = "unaryStart", type = "bool", options = [], operators = ["==", "!="], span = "0..0" } + +# Path cells + +[[test]] +expression = "grounding.stat|" +role = "path" +slot = { state = "path", span = "10..14", auto_open = false } + +[[test]] +expression = "|" +role = "path" +slot = { state = "path", span = "0..0" } + +[[test]] +expression = "customer.na|me" +role = "path" +slot = { state = "path", span = "9..13", auto_open = false } + +[[test]] +expression = "d(|" +role = "path" +slot = { state = "argument", type = "any", span = "2..2" } + +[[test]] +expression = "customer.status == \"|" +role = "path" +slot = { state = "inString", type = "status", options = ["open=Open case", "closed=Closed"], span = "19..20" } + +# Malformed input + +[[test]] +expression = "age > 1))) |" +slot = { state = "logical", type = "bool", operators = ["and", "or"], span = "11..11" } + +[[test]] +expression = "== |" +slot = { state = "value", options = [], span = "3..3" } + +[[test]] +expression = "status == \"open\" \"|" +slot = { state = "inString", type = "bool", options = [], span = "17..18" } + +[[test]] +expression = "in|" +slot = { state = "start", type = "bool", span = "0..2", auto_open = false } + +[[test]] +expression = "age > 1 and or |" +slot = { state = "start", type = "bool", span = "15..15" } + +# Const and nullable fields + +[[test]] +expression = "something == |" +scope = '{"Object":{"something":{"Const":"hello"}}}' +slot = { state = "value", type = "\"hello\"", options = ["hello"], span = "13..13" } + +[[test]] +expression = "something in [\"hello\", |" +scope = '{"Object":{"something":{"Const":"hello"}}}' +slot = { state = "listElement", type = "\"hello\"", options = [], span = "23..23" } + +[[test]] +expression = "something == |" +scope = '{"Object":{"something":{"Nullable":{"Const":"hello"}}}}' +slot = { state = "value", type = "\"hello\"?", options = ["hello", "null"], span = "13..13" } + +[[test]] +expression = "status == |" +scope = '{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}}' +slot = { state = "value", type = "status?", options = ["open=Open case", "closed=Closed", "null"], span = "10..10" } + +[[test]] +expression = "status ?? |" +scope = '{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}}' +slot = { state = "value", type = "status", options = ["open=Open case", "closed=Closed"], span = "10..10" } + +[[test]] +expression = "status ?|" +scope = '{"Object":{"status":{"Nullable":{"Enum":["status",["open","closed"]]}}}}' +slot = { state = "operator", operators = ["??"], span = "7..8" } + +[[test]] +expression = "aircraft.apuFault |" +scope = '{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}}' +slot = { state = "logical", type = "bool", operators = ["and", "or", "==", "!=", "??"], span = "18..18" } + +[[test]] +expression = "aircraft.apuFault == |" +scope = '{"Object":{"aircraft":{"Object":{"apuFault":{"Nullable":"Bool"}}}}}' +slot = { state = "value", type = "bool?", options = ["null"], span = "21..21" } + +[[test]] +expression = "amount == |" +slot = { state = "value", type = "number?", options = ["null"], span = "10..10" } +# Literal facts + +[[test]] +expression = "grade in ['a', 'b', 'c']" +literals = ["'a' enum a,b,c,d", "'b' enum a,b,c,d", "'c' enum a,b,c,d"] + +[[test]] +expression = "grade in ['a', 'b', 'c', |" +slot = { state = "listElement", type = "grade", options = ["d"], span = "25..25" } +literals = ["'a' enum a,b,c,d", "'b' enum a,b,c,d", "'c' enum a,b,c,d"] + +[[test]] +expression = "grade in ['a', 'x']" +literals = ["'a' enum a,b,c,d", "'x' enum a,b,c,d invalid"] + +[[test]] +expression = "status == \"open\" and customer.status != 'closed'" +literals = ["\"open\" enum open=Open case,closed=Closed", "'closed' enum open=Open case,closed=Closed"] + +[[test]] +expression = "status == \"open\" and" +literals = ["\"open\" enum open=Open case,closed=Closed"] + +[[test]] +expression = "status == \"op" +literals = [] + +[[test]] +expression = "\"open\", \"closed\"" +subject = "status" +literals = ["\"open\" enum open=Open case,closed=Closed", "\"closed\" enum open=Open case,closed=Closed"] + +[[test]] +expression = "age > 18 ? \"open\" : \"closed\"" +role = "value" +expected = "status" +literals = ["\"open\" enum open=Open case,closed=Closed", "\"closed\" enum open=Open case,closed=Closed"] + +[[test]] +expression = "some([\"open\"], # in statuses)" +literals = ["\"open\" enum open=Open case,closed=Closed"] + +[[test]] +expression = "active == true" +literals = ["true bool true"] + +[[test]] +expression = "since > d() and since < d().startOf(\"day\")" +literals = ["d() date now", "d().startOf(\"day\") date today"] + +[[test]] +expression = "since > d(\"2024-01-01\") and since < d(\"nope\")" +literals = ["d(\"2024-01-01\") date 2024-01-01", "d(\"nope\") date nope invalid"] + +[[test]] +expression = "since > d(\"2024-01-01\", \"Europe/Berlin\")" +literals = ["d(\"2024-01-01\", \"Europe/Berlin\") date 2024-01-01 tz=Europe/Berlin"] + +[[test]] +expression = "since > d(customer.since) and" +literals = ["d(customer.since) date field customer.since"] + +# Completions filtered by expected type + +[[test]] +expression = "s|" +role = "value" +expected = "bool" +includes = ["status", "since"] + +[[test]] +expression = "customer.|" +role = "value" +expected = "bool" +includes = ["name", "age", "address"] + +[[test]] +expression = "customer.|" +role = "value" +expected = "number" +includes = ["name", "age", "address"] + +[[test]] +expression = "|" +role = "value" +expected = "number" +includes = ["age", "customer", "name", "since"] + +[[test]] +expression = "age >= |" +includes = ["age", "name", "len", "upper", "customer"] + +[[test]] +expression = "len(customer.|)" +includes = ["name", "age", "address"] + +# Decimal literals and write paths + +[[test]] +expression = "0.|" +role = "value" +expected = "number" +slot = { state = "value" } +excludes = ["age", "customer", "len"] + +[[test]] +expression = "age > 12.|5" +excludes = ["age", "customer"] + +[[test]] +expression = "customer.|" +includes = ["age", "name"] + +[[test]] +expression = "data.|" +role = "path" +scope = '{"Object":{"data":"Any"}}' +excludes = ["add", "format", "year"] + +# Word boundaries follow the lexer + +[[test]] +expression = "age ≤ a|" +includes = ["age", "amount"] + +[[test]] +expression = "é|" +excludes = ["é"] + +[[test]] +expression = "customer.address.c|" +includes = ["city"] diff --git a/core/expression/tests/nl.rs b/core/expression/tests/nl.rs deleted file mode 100644 index b7bc3469..00000000 --- a/core/expression/tests/nl.rs +++ /dev/null @@ -1,1061 +0,0 @@ -use std::rc::Rc; - -use zen_expression::intellisense::IntelliSense; -use zen_expression::nl::{ - encode_string, EditHint, EnumOption, NlRequest, NlResult, NlTokenKind, OpChoice, OpSym, - TypeTag, WordSym, -}; -use zen_expression::variable::VariableType; - -fn op(sym: OpSym) -> NlTokenKind { - NlTokenKind::Op { - sym, - implied: false, - between: false, - } -} - -fn op_implied(sym: OpSym) -> NlTokenKind { - NlTokenKind::Op { - sym, - implied: true, - between: false, - } -} - -fn choices(syms: &[OpSym]) -> Vec { - syms.iter().copied().map(OpChoice::from).collect() -} - -fn obj(fields: &[(&str, VariableType)]) -> VariableType { - let object = VariableType::empty_object(); - if let VariableType::Object(map) = &object { - for (key, value) in fields { - map.borrow_mut().insert(Rc::from(*key), value.clone()); - } - } - object -} - -fn enum_t(name: &str, values: &[&str]) -> VariableType { - VariableType::Enum( - Some(Rc::from(name)), - values.iter().map(|v| Rc::from(*v)).collect(), - ) -} - -fn array(inner: VariableType) -> VariableType { - VariableType::Array(Rc::new(inner)) -} - -fn run(expr: &str, unary: bool, subject: Option, root: &VariableType) -> NlResult { - let mut intellisense = IntelliSense::new(); - intellisense.nl_tokenize( - &NlRequest { - id: "x".into(), - expression: expr.into(), - unary, - subject_type: subject, - }, - root, - ) -} - -fn kinds(result: &NlResult) -> Vec { - result.tokens.iter().map(|t| t.token.clone()).collect() -} - -#[test] -fn comparison_number() { - let root = obj(&[("customer", obj(&[("age", VariableType::Number)]))]); - let result = run("customer.age >= 18", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - assert_eq!( - kinds(&result), - vec![ - NlTokenKind::Field { - path: vec!["customer".into(), "age".into()], - ty: TypeTag::Number, - }, - op(OpSym::Gte), - NlTokenKind::Number { value: "18".into() }, - ] - ); - - let number = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Number { .. })) - .unwrap(); - assert_eq!(number.hint, None); -} - -#[test] -fn enum_membership_multiselect() { - let root = obj(&[("tier", enum_t("Tier", &["gold", "silver", "bronze"]))]); - let result = run("tier in ['gold', 'silver']", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!( - k[0], - NlTokenKind::Field { - path: vec!["tier".into()], - ty: TypeTag::Enum { index: 0 }, - } - ); - assert_eq!(k[1], op(OpSym::In)); - assert_eq!( - k[2], - NlTokenKind::EnumList { - selected: vec!["gold".into(), "silver".into()], - } - ); - assert_eq!(k.len(), 3); - assert_eq!( - result.tokens[2].hint, - Some(EditHint::MultiSelect { options: 0 }) - ); - - assert_eq!( - result.enums, - vec![vec![ - EnumOption { - label: "gold".to_string(), - source: Some("\"gold\"".to_string()), - }, - EnumOption { - label: "silver".to_string(), - source: Some("\"silver\"".to_string()), - }, - EnumOption { - label: "bronze".to_string(), - source: Some("\"bronze\"".to_string()), - }, - ]] - ); -} - -#[test] -fn enum_list_spans_whole_array() { - let root = obj(&[("tier", enum_t("Tier", &["gold", "silver"]))]); - let result = run("tier in ['gold']", false, None, &root); - - let list = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::EnumList { .. })) - .unwrap(); - let source = "tier in ['gold']"; - assert_eq!( - &source[list.span.0 as usize..list.span.1 as usize], - "['gold']" - ); -} - -#[test] -fn mixed_enum_array_stays_plain_list() { - let root = obj(&[ - ("tier", enum_t("Tier", &["gold", "silver"])), - ("other", VariableType::String), - ]); - let result = run("tier in ['gold', other]", false, None, &root); - - let k = kinds(&result); - assert!(k.contains(&NlTokenKind::ListOpen)); - assert!(k.contains(&NlTokenKind::ListClose)); - assert!(!k.iter().any(|t| matches!(t, NlTokenKind::EnumList { .. }))); -} - -#[test] -fn encode_string_swaps_quotes() { - assert_eq!(encode_string("plain"), Some("\"plain\"".to_string())); - assert_eq!(encode_string("6\" nail"), Some("'6\" nail'".to_string())); - assert_eq!(encode_string("it's"), Some("\"it's\"".to_string())); - assert_eq!(encode_string("both \" and '"), None); -} - -#[test] -fn closure_elides_top_level_alias() { - let root = obj(&[("countries", array(enum_t("Country", &["US", "CA", "GB"])))]); - let result = run("all(countries as c, c == 'US')", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!( - k[0], - NlTokenKind::Func { - sym: "all".into(), - closure: true, - } - ); - assert!(matches!(&k[1], NlTokenKind::Field { .. })); - assert_eq!(k[2], op(OpSym::Eq)); - assert!(matches!(&k[3], NlTokenKind::Str { .. })); -} - -#[test] -fn closure_elides_alias_in_member_paths() { - let root = obj(&[( - "order", - obj(&[("items", array(obj(&[("price", VariableType::Number)])))]), - )]); - let result = run( - "all(order.items as item, item.price > 100)", - false, - None, - &root, - ); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!( - k[0], - NlTokenKind::Func { - sym: "all".into(), - closure: true, - } - ); - assert!( - matches!(&k[1], NlTokenKind::Field { path, .. } if path.len() == 2 && path[0].as_ref() == "order") - ); - assert_eq!(k[2], NlTokenKind::Word { sym: WordSym::Has }); - assert!( - matches!(&k[3], NlTokenKind::Field { path, .. } if path.len() == 1 && path[0].as_ref() == "price") - ); - assert_eq!(k[4], op_implied(OpSym::Gt)); -} - -#[test] -fn closure_callback_reference() { - let root = obj(&[("orders", array(VariableType::Number))]); - let result = run("some(orders, # > 100)", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!( - k[0], - NlTokenKind::Func { - sym: "some".into(), - closure: true, - } - ); - assert!(matches!(&k[1], NlTokenKind::Field { .. })); - assert_eq!(k[2], op(OpSym::Gt)); -} - -#[test] -fn unary_number_cell() { - let result = run("> 18", true, Some(VariableType::Number), &VariableType::Any); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!(k[0], op_implied(OpSym::Gt)); - assert_eq!(k[1], NlTokenKind::Number { value: "18".into() }); -} - -#[test] -fn unary_enum_cell() { - let subject = enum_t("Tier", &["gold", "silver"]); - let result = run("'gold'", true, Some(subject), &VariableType::Any); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!(k[0], op_implied(OpSym::Eq)); - assert!(matches!(&k[1], NlTokenKind::Str { .. })); - assert_eq!(result.tokens[1].hint, Some(EditHint::Select { options: 0 })); -} - -#[test] -fn function_call_projects_args() { - let root = obj(&[("scores", array(VariableType::Number))]); - let result = run("sum(scores)", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!( - k[0], - NlTokenKind::Func { - sym: "sum".into(), - closure: false, - } - ); - assert!(matches!(&k[1], NlTokenKind::Field { .. })); - assert_eq!(k.len(), 2); -} - -#[test] -fn assignments_project_statements() { - let root = obj(&[("base", VariableType::Number)]); - let result = run("x = base * 2; y = x + 1; y > 10", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - let k = kinds(&result); - let tags: Vec<&str> = k - .iter() - .map(|t| match t { - NlTokenKind::Field { .. } => "field", - NlTokenKind::Assign => "assign", - NlTokenKind::StmtEnd => "stmtEnd", - NlTokenKind::Op { .. } => "op", - NlTokenKind::Number { .. } => "number", - other => panic!("unexpected token {other:?}"), - }) - .collect(); - assert_eq!( - tags, - vec![ - "field", "assign", "field", "op", "number", "stmtEnd", "field", "assign", "field", - "op", "number", "stmtEnd", "field", "op", "number", - ] - ); - assert_eq!( - k[0], - NlTokenKind::Field { - path: vec!["x".into()], - ty: TypeTag::Number, - } - ); -} - -#[test] -fn constant_index_member_projects_as_field() { - let root = obj(&[( - "revenue", - obj(&[( - "tiers", - array(obj(&[("itdReceipts", VariableType::Number)])), - )]), - )]); - let result = run("revenue.tiers[0].itdReceipts > 0", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - assert_eq!( - kinds(&result), - vec![ - NlTokenKind::Field { - path: vec!["revenue".into(), "tiers[0]".into(), "itdReceipts".into()], - ty: TypeTag::Number, - }, - op(OpSym::Gt), - NlTokenKind::Number { value: "0".into() }, - ] - ); -} - -#[test] -fn dynamic_index_member_stays_code() { - let root = obj(&[ - ("items", array(VariableType::Number)), - ("i", VariableType::Number), - ]); - let result = run("items[i]", false, None, &root); - - let k = kinds(&result); - assert_eq!( - k[0], - NlTokenKind::Code { - source: "items[i]".into(), - } - ); -} - -#[test] -fn slice_falls_back_to_code() { - let root = obj(&[("items", array(VariableType::Number))]); - let result = run("items[1:3]", false, None, &root); - - let k = kinds(&result); - assert_eq!( - k[0], - NlTokenKind::Code { - source: "items[1:3]".into(), - } - ); -} - -#[test] -fn unknown_function_half_stays_structured() { - let root = obj(&[("name", VariableType::String)]); - let result = run("fuzzyMatch(name, 'jon') > 0.8", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert!(matches!(&k[0], NlTokenKind::Field { .. })); - assert_eq!( - k[1], - NlTokenKind::Func { - sym: "fuzzyMatch".into(), - closure: false, - } - ); - assert!(matches!(&k[2], NlTokenKind::Str { .. })); - assert!(k.iter().any(|t| *t == op(OpSym::Gt))); -} - -#[test] -fn date_comparison_hints_date_picker() { - let root = obj(&[("start", VariableType::Date)]); - let result = run("start >= '2026-01-01'", false, None, &root); - - let date = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Str { .. })) - .unwrap(); - assert_eq!(date.hint, Some(EditHint::DatePicker)); -} - -#[test] -fn conditional_projects_words() { - let root = obj(&[("age", VariableType::Number)]); - let result = run("age > 18 ? 'adult' : 'minor'", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!(k[0], NlTokenKind::Word { sym: WordSym::If }); - assert!(matches!(&k[1], NlTokenKind::Field { .. })); - assert_eq!(k[2], op(OpSym::Gt)); - assert!(matches!(&k[3], NlTokenKind::Number { .. })); - assert_eq!(k[4], NlTokenKind::Word { sym: WordSym::Then }); - assert!(matches!(&k[5], NlTokenKind::Str { .. })); - assert_eq!( - k[6], - NlTokenKind::Word { - sym: WordSym::Otherwise, - } - ); - assert!(matches!(&k[7], NlTokenKind::Str { .. })); -} - -#[test] -fn template_string_projects_parts() { - let root = obj(&[("name", VariableType::String)]); - let result = run("`Hi ${name}!`", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!(k[0], NlTokenKind::TemplateOpen); - assert_eq!( - k[1], - NlTokenKind::TemplateText { - value: "Hi ".into(), - } - ); - assert_eq!( - k[2], - NlTokenKind::Field { - path: vec!["name".into()], - ty: TypeTag::String, - } - ); - assert_eq!(k[3], NlTokenKind::TemplateText { value: "!".into() }); - assert_eq!(k[4], NlTokenKind::TemplateClose); -} - -#[test] -fn unary_interval_projects_range() { - let result = run( - "[18..30)", - true, - Some(VariableType::Number), - &VariableType::Any, - ); - - let k = kinds(&result); - assert!(k.contains(&NlTokenKind::IntervalOpen { inclusive: true })); - assert!(k.contains(&NlTokenKind::Word { - sym: WordSym::RangeAnd, - })); - assert!(k.contains(&NlTokenKind::IntervalClose { inclusive: false })); -} - -#[test] -fn membership_over_interval_marks_between() { - let root = obj(&[("age", VariableType::Number)]); - let result = run("age in [18..65]", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - let k = kinds(&result); - assert!(k.contains(&NlTokenKind::Op { - sym: OpSym::In, - implied: false, - between: true, - })); -} - -#[test] -fn op_choices_carry_source() { - let root = obj(&[("tier", enum_t("Tier", &["gold", "silver"]))]); - let result = run("tier != 'gold'", false, None, &root); - - let op_tok = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Op { .. })) - .unwrap(); - let Some(EditHint::OpSelect { options }) = &op_tok.hint else { - panic!("expected opSelect hint"); - }; - let sources: Vec<&str> = options.iter().map(|o| o.source).collect(); - assert_eq!(sources, vec!["==", "!="]); -} - -#[test] -fn method_call_projects_receiver_and_args() { - let root = obj(&[("created", VariableType::Date)]); - let result = run("created.format('yyyy-MM-dd')", false, None, &root); - - let k = kinds(&result); - assert!(matches!(&k[0], NlTokenKind::Field { .. })); - assert_eq!( - k[1], - NlTokenKind::Method { - sym: "format".into(), - } - ); - assert!(matches!(&k[2], NlTokenKind::Str { .. })); - assert_eq!(k.len(), 3); -} - -#[test] -fn nested_closures_shadow_alias() { - let root = obj(&[( - "teams", - array(obj(&[("members", array(VariableType::String))])), - )]); - let result = run( - "some(teams as t, some(t.members as m, m == 'lee'))", - false, - None, - &root, - ); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - let elements: Vec<_> = k - .iter() - .filter_map(|t| match t { - NlTokenKind::Element { alias } => Some(alias.clone()), - _ => None, - }) - .collect(); - assert_eq!(elements, vec![Some("m".into()), Some("m".into())]); - assert!(!k - .iter() - .any(|t| matches!(t, NlTokenKind::Field { path, .. } if path == &vec![Box::from("m")]))); -} - -#[test] -fn json_wire_shape() { - let root = obj(&[("customer", obj(&[("age", VariableType::Number)]))]); - let result = run("customer.age >= 18", false, None, &root); - - let value = serde_json::to_value(&result).unwrap(); - let tokens = value["tokens"].as_array().unwrap(); - - assert_eq!(tokens[0]["token"]["t"], "field"); - assert_eq!(tokens[0]["token"]["path"][0], "customer"); - assert_eq!(tokens[0]["token"]["ty"]["t"], "number"); - assert_eq!(tokens[1]["token"]["t"], "op"); - assert_eq!(tokens[1]["token"]["sym"], "gte"); - assert_eq!(tokens[2]["token"]["t"], "number"); - assert_eq!(tokens[2]["token"]["value"], "18"); - assert_eq!(tokens[2].get("hint"), None); -} - -#[test] -fn batch_correlates_ids() { - let root = obj(&[("age", VariableType::Number)]); - let mut intellisense = IntelliSense::new(); - let requests = vec![ - NlRequest { - id: "a".into(), - expression: "age > 1".into(), - unary: false, - subject_type: None, - }, - NlRequest { - id: "b".into(), - expression: "age < 5".into(), - unary: false, - subject_type: None, - }, - ]; - - let results = intellisense.nl_tokenize_batch(&requests, &root); - assert_eq!(results.len(), 2); - assert_eq!(results[0].id, "a"); - assert_eq!(results[1].id, "b"); -} - -#[test] -fn op_hint_ordered_for_numbers() { - let root = obj(&[("customer", obj(&[("age", VariableType::Number)]))]); - let result = run("customer.age >= 18", false, None, &root); - - let op = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Op { .. })) - .unwrap(); - assert_eq!( - op.hint, - Some(EditHint::OpSelect { - options: choices(&[ - OpSym::Gt, - OpSym::Gte, - OpSym::Lt, - OpSym::Lte, - OpSym::Eq, - OpSym::Ne, - ]) - }) - ); -} - -#[test] -fn op_hint_equality_only_for_enums() { - let root = obj(&[( - "customer", - obj(&[("tier", enum_t("Tier", &["gold", "silver"]))]), - )]); - let result = run("customer.tier == 'gold'", false, None, &root); - - let op = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Op { .. })) - .unwrap(); - assert_eq!( - op.hint, - Some(EditHint::OpSelect { - options: choices(&[OpSym::Eq, OpSym::Ne]) - }) - ); -} - -#[test] -fn op_hint_membership_and_joiners() { - let root = obj(&[ - ("age", VariableType::Number), - ("tier", enum_t("Tier", &["gold", "silver"])), - ]); - let result = run("age > 18 and tier in ['gold']", false, None, &root); - - let ops: Vec<_> = result - .tokens - .iter() - .filter(|t| matches!(t.token, NlTokenKind::Op { .. })) - .collect(); - assert_eq!(ops.len(), 3); - assert_eq!( - ops[1].hint, - Some(EditHint::OpSelect { - options: choices(&[OpSym::And, OpSym::Or]) - }) - ); - assert_eq!( - ops[2].hint, - Some(EditHint::OpSelect { - options: choices(&[OpSym::In, OpSym::NotIn]) - }) - ); -} - -#[test] -fn infix_predicate_functions() { - let root = obj(&[("name", VariableType::String)]); - let result = run("contains(name, 'jo')", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert!(matches!(&k[0], NlTokenKind::Field { .. })); - assert_eq!( - k[1], - NlTokenKind::Func { - sym: "contains".into(), - closure: false, - } - ); - assert!(matches!(&k[2], NlTokenKind::Str { .. })); - assert_eq!(k.len(), 3); -} - -#[test] -fn complex_single_arg_keeps_group() { - let root = obj(&[("total", VariableType::Number)]); - let result = run("round(total * 0.1)", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!( - k[0], - NlTokenKind::Func { - sym: "round".into(), - closure: false, - } - ); - assert_eq!(k[1], NlTokenKind::GroupOpen); - assert_eq!(*k.last().unwrap(), NlTokenKind::GroupClose); -} - -fn ticket_array() -> VariableType { - array(enum_t("Ticket", &["award", "regular", "upgrade"])) -} - -fn quant_hint(result: &NlResult) -> (&EditHint, NlTokenKind) { - let token = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Op { .. })) - .unwrap(); - (token.hint.as_ref().unwrap(), token.token.clone()) -} - -#[test] -fn contains_any_unary() { - let result = run( - "some($, # in ['award'])", - true, - Some(ticket_array()), - &VariableType::Any, - ); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!(k[0], op_implied(OpSym::ContainsAny)); - assert_eq!( - k[1], - NlTokenKind::EnumList { - selected: vec!["award".into()], - } - ); - assert_eq!(k.len(), 2); - - let (hint, _) = quant_hint(&result); - let EditHint::QuantSelect { - options, - subject, - list, - } = hint - else { - panic!("expected quant select, got {hint:?}"); - }; - assert_eq!(subject.as_ref(), "$"); - assert_eq!(list.as_ref(), "['award']"); - assert_eq!( - options, - &vec![ - OpSym::ContainsAny, - OpSym::ContainsAll, - OpSym::ContainsNone, - OpSym::ContainsOnly, - ] - ); -} - -#[test] -fn contains_all_flipped_unary() { - let result = run( - "all(['award', 'regular'], # in $)", - true, - Some(ticket_array()), - &VariableType::Any, - ); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!(k[0], op_implied(OpSym::ContainsAll)); - assert_eq!( - k[1], - NlTokenKind::EnumList { - selected: vec!["award".into(), "regular".into()], - } - ); - - let (hint, _) = quant_hint(&result); - let EditHint::QuantSelect { subject, list, .. } = hint else { - panic!("expected quant select, got {hint:?}"); - }; - assert_eq!(subject.as_ref(), "$"); - assert_eq!(list.as_ref(), "['award', 'regular']"); -} - -#[test] -fn contains_none_unary() { - let result = run( - "none($, # in ['award'])", - true, - Some(ticket_array()), - &VariableType::Any, - ); - assert_eq!(kinds(&result)[0], op_implied(OpSym::ContainsNone)); - - let negated = run( - "all($, # not in ['award'])", - true, - Some(ticket_array()), - &VariableType::Any, - ); - assert_eq!(kinds(&negated)[0], op_implied(OpSym::ContainsNone)); -} - -#[test] -fn contains_only_unary() { - let result = run( - "all($, # in ['award', 'regular'])", - true, - Some(ticket_array()), - &VariableType::Any, - ); - assert_eq!(kinds(&result)[0], op_implied(OpSym::ContainsOnly)); -} - -#[test] -fn contains_any_field_subject() { - let root = obj(&[( - "order", - obj(&[("tags", array(enum_t("Tag", &["vip", "fragile"])))]), - )]); - let result = run("some(order.tags, # in ['vip'])", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert!( - matches!(&k[0], NlTokenKind::Field { path, .. } if path.len() == 2 && path[0].as_ref() == "order") - ); - assert_eq!(k[1], op(OpSym::ContainsAny)); - assert_eq!( - k[2], - NlTokenKind::EnumList { - selected: vec!["vip".into()], - } - ); -} - -#[test] -fn contains_single_flip_unary() { - let result = run( - "'award' in $", - true, - Some(ticket_array()), - &VariableType::Any, - ); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - - let k = kinds(&result); - assert_eq!(k[0], op_implied(OpSym::Contains)); - assert_eq!( - k[1], - NlTokenKind::Str { - value: "award".into(), - } - ); - - let op_token = &result.tokens[0]; - assert_eq!( - op_token.hint, - Some(EditHint::OpSelect { - options: choices(&[OpSym::Contains, OpSym::NotContains]), - }) - ); - let needle = &result.tokens[1]; - assert!(matches!(needle.hint, Some(EditHint::Select { .. }))); - - let negated = run( - "'award' not in $", - true, - Some(ticket_array()), - &VariableType::Any, - ); - assert_eq!(kinds(&negated)[0], op_implied(OpSym::NotContains)); -} - -#[test] -fn contains_flip_keeps_literal_lists() { - let root = obj(&[("tier", enum_t("Tier", &["gold", "silver"]))]); - let result = run("tier in ['gold']", false, None, &root); - - let k = kinds(&result); - assert!(!k.iter().any(|kind| matches!( - kind, - NlTokenKind::Op { - sym: OpSym::Contains, - .. - } - ))); -} - -#[test] -fn quantifier_func_select() { - let root = obj(&[("orders", array(VariableType::Number))]); - let result = run("some(orders, # > 100)", false, None, &root); - - let func = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Func { .. })) - .unwrap(); - assert_eq!(func.span, (0, 4)); - let Some(EditHint::FuncSelect { options }) = &func.hint else { - panic!("expected func select, got {:?}", func.hint); - }; - assert_eq!( - options - .iter() - .map(|option| option.as_ref()) - .collect::>(), - vec!["all", "some", "none"] - ); -} - -#[test] -fn non_array_subject_falls_through() { - let root = obj(&[("name", VariableType::String)]); - let result = run("some(name, # in ['a'])", false, None, &root); - - let k = kinds(&result); - assert!(matches!( - &k[0], - NlTokenKind::Func { sym, closure: true } if sym.as_ref() == "some" - )); -} - -#[test] -fn date_constructor_string_elides_func_and_hints_picker() { - let root = obj(&[("startDate", VariableType::Date)]); - let result = run("startDate > d('2024-01-15')", false, None, &root); - - assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); - let k = kinds(&result); - assert!(!k - .iter() - .any(|t| matches!(t, NlTokenKind::Func { .. } | NlTokenKind::GroupOpen))); - let date = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Str { .. })) - .unwrap(); - assert_eq!( - date.token, - NlTokenKind::Str { - value: "2024-01-15".into() - } - ); - assert_eq!(date.hint, Some(EditHint::DatePicker)); - assert_eq!(date.span, (14, 26)); -} - -#[test] -fn date_constructor_with_timezone_keeps_func_and_hints_first_arg() { - let root = obj(&[("startDate", VariableType::Date)]); - let result = run( - "startDate > d('2024-01-15 10:30', 'Europe/Berlin')", - false, - None, - &root, - ); - - let k = kinds(&result); - assert!(k - .iter() - .any(|t| matches!(t, NlTokenKind::Func { sym, closure: false } if sym.as_ref() == "d"))); - let strs: Vec<_> = result - .tokens - .iter() - .filter(|t| matches!(t.token, NlTokenKind::Str { .. })) - .collect(); - assert_eq!(strs.len(), 2); - assert_eq!(strs[0].hint, Some(EditHint::DatePicker)); - assert_eq!(strs[1].hint, None); -} - -#[test] -fn bare_string_compared_to_date_field_hints_picker() { - let root = obj(&[("startDate", VariableType::Date)]); - let result = run("startDate >= '2024-01-15'", false, None, &root); - - let date = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Str { .. })) - .unwrap(); - assert_eq!(date.hint, Some(EditHint::DatePicker)); -} - -#[test] -fn unary_date_subject_interval_hints_pickers() { - let result = run( - "[d('2024-01-01')..d('2024-12-31')]", - true, - Some(VariableType::Date), - &VariableType::empty_object(), - ); - - let strs: Vec<_> = result - .tokens - .iter() - .filter(|t| matches!(t.token, NlTokenKind::Str { .. })) - .collect(); - assert_eq!(strs.len(), 2); - assert!(strs.iter().all(|t| t.hint == Some(EditHint::DatePicker))); -} - -#[test] -fn date_method_arg_hints_picker() { - let root = obj(&[("startDate", VariableType::Date)]); - let result = run("startDate.isAfter('2024-01-15')", false, None, &root); - - let date = result - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Str { .. })) - .unwrap(); - assert_eq!(date.hint, Some(EditHint::DatePicker)); - - let format = run("startDate.format('%Y-%m')", false, None, &root); - let pattern = format - .tokens - .iter() - .find(|t| matches!(t.token, NlTokenKind::Str { .. })) - .unwrap(); - assert_eq!(pattern.hint, None); -} - -#[test] -fn bare_interval_endpoints_hint_picker() { - let root = obj(&[("startDate", VariableType::Date)]); - let result = run( - "startDate in ['2024-01-01'..'2024-12-31']", - false, - None, - &root, - ); - - let strs: Vec<_> = result - .tokens - .iter() - .filter(|t| matches!(t.token, NlTokenKind::Str { .. })) - .collect(); - assert_eq!(strs.len(), 2); - assert!(strs.iter().all(|t| t.hint == Some(EditHint::DatePicker))); -} diff --git a/core/expression/tests/slots.rs b/core/expression/tests/slots.rs new file mode 100644 index 00000000..bbeb7cb4 --- /dev/null +++ b/core/expression/tests/slots.rs @@ -0,0 +1,328 @@ +use std::rc::Rc; + +use serde::Deserialize; +use zen_expression::intellisense::completion::Completions; +use zen_expression::intellisense::IntelliSense; +use zen_expression::slot::{DateArg, LabelResolver, LiteralFact, Literals, Slot, SlotRole}; +use zen_expression::variable::VariableType; + +const BASE_SCOPE: &str = r#"{"Object":{ + "age":"Number","name":"String","active":"Bool","since":"Date", + "amount":{"Nullable":"Number"}, + "status":{"Enum":["status",["open","closed"]]}, + "tier":{"Enum":[null,["gold","silver"]]}, + "mood":{"Enum":["mood",["happy","sad"]]}, + "grade":{"Enum":["grade",["a","b","c","d"]]}, + "customer":{"Object":{"age":"Number","name":"String","since":"Date", + "status":{"Enum":["status",["open","closed"]]}, + "address":{"Object":{"city":"String"}}}}, + "items":{"Array":{"Object":{"price":"Number","status":{"Enum":["status",["open","closed"]]}}}}, + "tags":{"Array":"String"}, + "statuses":{"Array":{"Enum":["status",["open","closed"]]}}, + "grounding":{"Object":{"status":{"Enum":["status",["open","closed"]]}}} +}}"#; + +#[derive(Deserialize)] +struct TestFile { + test: Vec, +} + +#[derive(Deserialize)] +struct TestCase { + expression: String, + role: Option, + subject: Option, + scope: Option, + expected: Option, + slot: Option, + literals: Option>, + #[serde(default)] + includes: Vec, + #[serde(default)] + excludes: Vec, +} + +#[derive(Deserialize)] +struct ExpectedSlot { + state: String, + #[serde(rename = "type")] + kind: Option, + options: Option>, + operators: Option>, + span: Option, + auto_open: Option, +} + +impl TestCase { + fn role(&self) -> SlotRole { + match self.role.as_deref() { + None if self.subject.is_some() => SlotRole::Unary, + None | Some("condition") => SlotRole::Condition, + Some("value") => SlotRole::Value, + Some("path") => SlotRole::Path, + Some(other) => panic!("unknown role {other}"), + } + } + + fn scope(&self) -> VariableType { + let scope = parse_type(self.scope.as_deref().unwrap_or(BASE_SCOPE)); + if let (Some(subject), VariableType::Object(map)) = (&self.subject, &scope) { + map.borrow_mut().insert(Rc::from("$"), parse_type(subject)); + } + scope + } + + fn expected(&self) -> Option { + match (&self.expected, self.role()) { + (Some(spec), _) => Some(parse_type(spec)), + (None, SlotRole::Condition) => Some(VariableType::Bool), + _ => None, + } + } + + fn check(&self, is: &mut IntelliSense) -> Vec { + let (source, pos) = match self.expression.find('|') { + Some(caret) => (self.expression.replacen('|', "", 1), caret), + None => (self.expression.clone(), self.expression.len()), + }; + let scope = self.scope(); + let result = is.slot( + &source, + pos as u32, + self.subject.is_some(), + self.role(), + &scope, + self.expected().as_ref(), + ); + let mut failures = Vec::new(); + if let Some(expected) = &self.slot { + failures.extend(expected.check(&result.slot)); + } + if let Some(expected) = &self.literals { + let actual = render_literals(&source, &result.literals); + if &actual != expected { + failures.push(format!("literals: wanted {expected:?}, got {actual:?}")); + } + } + if !self.includes.is_empty() || !self.excludes.is_empty() { + let labels: Vec = + Completions::from_slot(&source, pos as u32, &scope, &result.slot) + .into_iter() + .map(|c| c.label) + .collect(); + for label in self.includes.iter().filter(|l| !labels.contains(l)) { + failures.push(format!("completion {label} missing from {labels:?}")); + } + for label in self.excludes.iter().filter(|l| labels.contains(l)) { + failures.push(format!("completion {label} unexpected in {labels:?}")); + } + } + failures + } +} + +impl ExpectedSlot { + fn check(&self, slot: &Slot) -> Vec { + let state = serde_json::to_value(slot.state).unwrap(); + let options: Vec = slot + .options + .iter() + .map(|o| match o.label == o.value { + true => o.value.clone(), + false => format!("{}={}", o.value, o.label), + }) + .collect(); + let operators: Vec = slot.operators.iter().map(|o| o.to_string()).collect(); + let fields = [ + ( + "state", + Some(self.state.clone()), + state.as_str().unwrap().to_string(), + ), + ( + "type", + self.kind.clone(), + slot.expected + .as_ref() + .map(|t| t.to_string()) + .unwrap_or_default(), + ), + ( + "options", + self.options.as_ref().map(|o| o.join(",")), + options.join(","), + ), + ( + "operators", + self.operators.as_ref().map(|o| o.join(",")), + operators.join(","), + ), + ( + "span", + self.span.clone(), + format!("{}..{}", slot.replace_span.0, slot.replace_span.1), + ), + ( + "auto_open", + self.auto_open.map(|a| a.to_string()), + slot.auto_open.to_string(), + ), + ]; + fields + .into_iter() + .filter_map(|(field, wanted, actual)| { + let wanted = wanted?; + (wanted != actual).then(|| format!("{field}: wanted {wanted:?}, got {actual:?}")) + }) + .collect() + } +} + +fn parse_type(spec: &str) -> VariableType { + let json = match spec { + "bool" => r#""Bool""#, + "number" => r#""Number""#, + "string" => r#""String""#, + "date" => r#""Date""#, + "any" => r#""Any""#, + "status" => r#"{"Enum":["status",["open","closed"]]}"#, + "grade" => r#"{"Enum":["grade",["a","b","c","d"]]}"#, + other => other, + }; + serde_json::from_str(json).unwrap_or_else(|e| panic!("type {spec}: {e}")) +} + +fn render_literals(source: &str, literals: &Literals) -> Vec { + literals + .facts + .iter() + .map(|fact| { + let span = fact.span(); + let text = &source[span.0 as usize..span.1 as usize]; + match fact { + LiteralFact::Enum { + valid, enum_index, .. + } => { + let options: Vec = literals.enums[*enum_index as usize] + .options + .iter() + .map(|o| match o.label == o.value { + true => o.value.clone(), + false => format!("{}={}", o.value, o.label), + }) + .collect(); + let invalid = if *valid { "" } else { " invalid" }; + format!("{text} enum {}{invalid}", options.join(",")) + } + LiteralFact::Date { arg, .. } => match arg { + DateArg::Now => format!("{text} date now"), + DateArg::Today => format!("{text} date today"), + DateArg::Field { path } => format!("{text} date field {path}"), + DateArg::Literal { value, valid, tz } => { + let invalid = if *valid { "" } else { " invalid" }; + let tz = tz.as_ref().map(|z| format!(" tz={z}")).unwrap_or_default(); + format!("{text} date {value}{tz}{invalid}") + } + }, + LiteralFact::Bool { value, .. } => format!("{text} bool {value}"), + } + }) + .collect() +} + +fn labels() -> LabelResolver { + Rc::new(|name: &str, value: &str| { + let label = match (name, value) { + ("status", "open") => "Open case", + ("status", "closed") => "Closed", + ("mood", "happy") => "😀 Happy", + ("mood", "sad") => "😢 Sad", + _ => return None, + }; + Some(label.to_string()) + }) +} + +#[test] +fn slots() { + let file: TestFile = toml::from_str(include_str!("data/slots.toml")).expect("slots.toml"); + let mut is = IntelliSense::new(); + is.set_labels(Some(labels())); + let failures: Vec = file + .test + .iter() + .flat_map(|test| { + test.check(&mut is) + .into_iter() + .map(move |f| format!("{}\n {f}", test.expression)) + }) + .collect(); + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn deeply_nested_lists_do_not_overflow_the_stack() { + let scope = parse_type(BASE_SCOPE); + for unary in [false, true] { + let source = format!("{}a", "[".repeat(20_000)); + let handle = std::thread::Builder::new() + .stack_size(1024 * 1024) + .spawn(move || { + let scope = parse_type(BASE_SCOPE); + let mut is = IntelliSense::new(); + is.slot( + &source, + source.len() as u32, + unary, + SlotRole::Value, + &scope, + None, + ); + is.inspect(&source, source.len() as u32, unary, SlotRole::Value, &scope); + }) + .unwrap(); + handle.join().expect("no stack overflow"); + } + drop(scope); +} + +#[test] +fn date_slots_offer_every_field() { + let scope: VariableType = + serde_json::from_str(r#"{"Object":{"since":"Date","appDate":"String","age":"Number"}}"#) + .unwrap(); + let date = VariableType::Date; + let labels = |source: &str, expected: Option<&VariableType>| -> Vec { + let mut is = IntelliSense::new(); + let pos = source.len() as u32; + let result = is.slot(source, pos, false, SlotRole::Value, &scope, expected); + Completions::from_slot(source, pos, &scope, &result.slot) + .into_iter() + .map(|c| c.label) + .collect() + }; + + for (source, expected) in [ + ("d().isAfter(", None), + ("", Some(&date)), + ("app", Some(&date)), + ] { + let got = labels(source, expected); + assert!(got.contains(&"appDate".to_string()), "{source:?}: {got:?}"); + assert_eq!( + got.contains(&"age".to_string()), + source != "app", + "{source:?}: {got:?}" + ); + } + for source in [ + "since > ", + "since == ", + "since in [", + "d().isAfter(appDate) and since < ", + ] { + let got = labels(source, None); + assert!(got.contains(&"appDate".to_string()), "{source:?}: {got:?}"); + assert!(got.contains(&"age".to_string()), "{source:?}: {got:?}"); + } +} diff --git a/core/types/src/variable_type/mod.rs b/core/types/src/variable_type/mod.rs index 30ca01f9..a3f13c3c 100644 --- a/core/types/src/variable_type/mod.rs +++ b/core/types/src/variable_type/mod.rs @@ -3,7 +3,8 @@ mod util; use ahash::HashMap; pub use ahash::HashMapExt as VariableMapExt; -use serde::{Deserialize, Serialize}; +use serde::ser::{SerializeMap, SerializeTupleVariant}; +use serde::{Deserialize, Serialize, Serializer}; use std::cell::RefCell; use std::fmt::{Display, Write}; use std::hash::{Hash, Hasher}; @@ -11,7 +12,9 @@ use std::rc::Rc; type RcCell = Rc>; -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub const MAX_TYPE_DEPTH: usize = 32; + +#[derive(Clone, Eq, PartialEq, Deserialize)] pub enum VariableType { Any, Null, @@ -34,12 +37,192 @@ impl VariableType { } } +const TYPE_NAME: &str = "VariableType"; + +struct Guarded<'a> { + inner: &'a VariableType, + path: &'a [*const ()], +} + +struct GuardedFields<'a> { + fields: &'a HashMap, VariableType>, + path: Vec<*const ()>, +} + +impl Serialize for VariableType { + fn serialize(&self, serializer: S) -> Result { + Guarded { + inner: self, + path: &[], + } + .serialize(serializer) + } +} + +impl Serialize for Guarded<'_> { + fn serialize(&self, serializer: S) -> Result { + match self.inner { + VariableType::Any => serializer.serialize_unit_variant(TYPE_NAME, 0, "Any"), + VariableType::Null => serializer.serialize_unit_variant(TYPE_NAME, 1, "Null"), + VariableType::Bool => serializer.serialize_unit_variant(TYPE_NAME, 2, "Bool"), + VariableType::String => serializer.serialize_unit_variant(TYPE_NAME, 3, "String"), + VariableType::Number => serializer.serialize_unit_variant(TYPE_NAME, 4, "Number"), + VariableType::Date => serializer.serialize_unit_variant(TYPE_NAME, 5, "Date"), + VariableType::Interval => serializer.serialize_unit_variant(TYPE_NAME, 6, "Interval"), + VariableType::Array(inner) => serializer.serialize_newtype_variant( + TYPE_NAME, + 7, + "Array", + &Guarded { + inner, + path: self.path, + }, + ), + VariableType::Object(obj) => { + let ptr = Rc::as_ptr(obj) as *const (); + let cut = self.path.len() >= MAX_TYPE_DEPTH || self.path.contains(&ptr); + let empty = HashMap::default(); + let borrowed; + let fields: &HashMap, VariableType> = if cut { + &empty + } else { + borrowed = obj.borrow(); + &borrowed + }; + let mut path = self.path.to_vec(); + path.push(ptr); + serializer.serialize_newtype_variant( + TYPE_NAME, + 8, + "Object", + &GuardedFields { fields, path }, + ) + } + VariableType::Const(c) => { + serializer.serialize_newtype_variant(TYPE_NAME, 9, "Const", c.as_ref()) + } + VariableType::Enum(name, values) => { + let mut tv = serializer.serialize_tuple_variant(TYPE_NAME, 10, "Enum", 2)?; + tv.serialize_field(name)?; + tv.serialize_field(values)?; + tv.end() + } + VariableType::Nullable(inner) => serializer.serialize_newtype_variant( + TYPE_NAME, + 11, + "Nullable", + &Guarded { + inner, + path: self.path, + }, + ), + } + } +} + +impl Serialize for GuardedFields<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(self.fields.len()))?; + for (key, value) in self.fields.iter() { + map.serialize_entry( + key.as_ref(), + &Guarded { + inner: value, + path: &self.path, + }, + )?; + } + map.end() + } +} + impl Default for VariableType { fn default() -> Self { VariableType::Null } } +impl std::fmt::Debug for VariableType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt( + &Guarded { + inner: self, + path: &[], + }, + f, + ) + } +} + +impl std::fmt::Debug for Guarded<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.inner { + VariableType::Any => f.write_str("Any"), + VariableType::Null => f.write_str("Null"), + VariableType::Bool => f.write_str("Bool"), + VariableType::String => f.write_str("String"), + VariableType::Number => f.write_str("Number"), + VariableType::Date => f.write_str("Date"), + VariableType::Interval => f.write_str("Interval"), + VariableType::Array(inner) | VariableType::Nullable(inner) => { + let name = if matches!(self.inner, VariableType::Array(_)) { + "Array" + } else { + "Nullable" + }; + f.debug_tuple(name) + .field(&Guarded { + inner, + path: self.path, + }) + .finish() + } + VariableType::Const(value) => f.debug_tuple("Const").field(value).finish(), + VariableType::Enum(name, values) => { + f.debug_tuple("Enum").field(name).field(values).finish() + } + VariableType::Object(obj) => { + let ptr = Rc::as_ptr(obj) as *const (); + if self.path.len() >= MAX_TYPE_DEPTH || self.path.contains(&ptr) { + return f.write_str("Object()"); + } + let mut path = self.path.to_vec(); + path.push(ptr); + f.debug_tuple("Object") + .field(&DebugCell(GuardedFields { + fields: &obj.borrow(), + path, + })) + .finish() + } + } + } +} + +struct DebugCell<'a>(GuardedFields<'a>); + +impl std::fmt::Debug for DebugCell<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RefCell").field("value", &self.0).finish() + } +} + +impl std::fmt::Debug for GuardedFields<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_map() + .entries(self.fields.iter().map(|(key, value)| { + ( + key, + Guarded { + inner: value, + path: &self.path, + }, + ) + })) + .finish() + } +} + impl Display for VariableType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/core/types/src/variable_type/util.rs b/core/types/src/variable_type/util.rs index 34fb7f4e..90af41bc 100644 --- a/core/types/src/variable_type/util.rs +++ b/core/types/src/variable_type/util.rs @@ -1,5 +1,5 @@ -use crate::variable_type::VariableType; -use ahash::{HashMap, HashMapExt}; +use crate::variable_type::{MAX_TYPE_DEPTH, VariableType}; +use ahash::{HashMap, HashMapExt, HashSet}; use rust_decimal::prelude::Zero; use std::cell::RefCell; use std::collections::hash_map::Entry; @@ -42,11 +42,25 @@ impl VariableType { } pub fn satisfies(&self, constraint: &Self) -> bool { + self.satisfies_at(constraint, 0, &mut HashSet::default()) + } + + fn satisfies_at( + &self, + constraint: &Self, + depth: usize, + visited: &mut HashSet<(*const (), *const (), usize)>, + ) -> bool { + if depth > MAX_TYPE_DEPTH { + return true; + } match (self, constraint) { (VariableType::Any, _) | (_, VariableType::Any) => true, - (VariableType::Nullable(a), VariableType::Nullable(b)) => a.satisfies(b), + (VariableType::Nullable(a), VariableType::Nullable(b)) => { + a.satisfies_at(b, depth, visited) + } (VariableType::Nullable(_), _) => false, - (other, VariableType::Nullable(inner)) => other.satisfies(inner), + (other, VariableType::Nullable(inner)) => other.satisfies_at(inner, depth, visited), (VariableType::Null, VariableType::Null) => true, (VariableType::Bool, VariableType::Bool) => true, @@ -56,13 +70,24 @@ impl VariableType { (VariableType::Number, VariableType::Date) => true, (_, VariableType::Date) if self.widen().is_string() => true, (VariableType::Interval, VariableType::Interval) => true, - (VariableType::Array(a1), VariableType::Array(a2)) => a1.satisfies(a2), + (VariableType::Array(a1), VariableType::Array(a2)) => { + a1.satisfies_at(a2, depth + 1, visited) + } (VariableType::Object(o1), VariableType::Object(o2)) => { + if Rc::ptr_eq(o1, o2) + || !visited.insert(( + Rc::as_ptr(o1) as *const (), + Rc::as_ptr(o2) as *const (), + depth, + )) + { + return true; + } let o1 = o1.borrow(); let o2 = o2.borrow(); o2.iter().all(|(k, v)| match o1.get(k) { - Some(tv) => tv.satisfies(v), + Some(tv) => tv.satisfies_at(v, depth + 1, visited), None => matches!( v, VariableType::Any | VariableType::Null | VariableType::Nullable(_) @@ -141,6 +166,18 @@ impl VariableType { } pub fn merge(&self, other: &Self) -> Self { + self.merge_at(other, 0, &mut HashMap::default()) + } + + fn merge_at( + &self, + other: &Self, + depth: usize, + memo: &mut HashMap<(*const (), *const (), usize), VariableType>, + ) -> Self { + if depth > MAX_TYPE_DEPTH { + return VariableType::Any; + } let (left, left_nullable) = self.unwrap_nullable(); let (right, right_nullable) = other.unwrap_nullable(); let nullable = left_nullable || right_nullable; @@ -164,32 +201,40 @@ impl VariableType { (VariableType::Any, other) | (other, VariableType::Any) => { VariableType::Array(Rc::new(other.clone())) } - (l, r) => VariableType::Array(Rc::new(l.merge(r))), + (l, r) => VariableType::Array(Rc::new(l.merge_at(r, depth + 1, memo))), } } } + (VariableType::Object(o1), VariableType::Object(o2)) if Rc::ptr_eq(o1, o2) => { + VariableType::Object(o1.clone()) + } (VariableType::Object(o1), VariableType::Object(o2)) => { - let o1 = o1.borrow(); - let o2 = o2.borrow(); - - let mut merged = HashMap::with_capacity(o1.len().max(o2.len())); - for (k, v) in o1.iter() { - merged.insert(k.clone(), v.clone()); - } - - for (k, v) in o2.iter() { - match merged.entry(k.clone()) { - Entry::Occupied(mut entry) => { - let current = entry.get(); - entry.insert(current.merge(v)); - } - Entry::Vacant(entry) => { - entry.insert(v.clone()); + let key = ( + Rc::as_ptr(o1) as *const (), + Rc::as_ptr(o2) as *const (), + depth, + ); + if let Some(cached) = memo.get(&key) { + cached.shallow_clone() + } else { + let o1 = o1.borrow(); + let o2 = o2.borrow(); + let mut merged = o1.clone(); + for (k, v) in o2.iter() { + match merged.entry(k.clone()) { + Entry::Occupied(mut entry) => { + let value = entry.get().merge_at(v, depth + 1, memo); + entry.insert(value); + } + Entry::Vacant(entry) => { + entry.insert(v.clone()); + } } } + let result = VariableType::Object(Rc::new(RefCell::new(merged))); + memo.insert(key, result.shallow_clone()); + result } - - VariableType::Object(Rc::new(RefCell::new(merged))) } (VariableType::Const(c), VariableType::Enum(_, values)) => { let mut merged = values.clone();