mirror of
https://github.com/gorules/zen.git
synced 2026-10-03 16:02:27 +00:00
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
This commit is contained in:
Vendored
+83
-136
@@ -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<string, PolicyVariableType> }
|
||||
| { 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<enum>` 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+105
-150
@@ -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<string, PolicyVariableType> }
|
||||
| { 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<enum>` 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<PolicyOutputProperty>
|
||||
conditionalSchema(req: PolicyScopeRequest): PolicyConditionalSchema
|
||||
inspect(cursor: PolicyExpressionCursor): PolicyInspectResult | null
|
||||
nl(policyPath: string): PolicyNlExpression[]
|
||||
nlTokenize(cursor: PolicyExpressionCursor, text: string): NlResult | null
|
||||
completions(cursor: PolicyExpressionCursor): Array<PolicyCompletion>
|
||||
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<string>
|
||||
crossComponentWriteConflicts(): Array<PolicyWriteConflict>
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
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<SlotRequest>, 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<string, Record<string, string>> | null
|
||||
}
|
||||
|
||||
export interface ZenConfig {
|
||||
nodesInContext?: boolean
|
||||
functionTimeoutMillis?: number
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,62 +60,7 @@ pub async fn render_template(template: String, context: Value) -> napi::Result<V
|
||||
.map_err(|_| anyhow!("Hook timed out"))?
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn nl_encode_string(value: String) -> Option<String> {
|
||||
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<Value>,
|
||||
}
|
||||
|
||||
#[napi(
|
||||
ts_args_type = "requests: NlTokenizeRequest[], rootType: PolicyVariableType, strict?: boolean",
|
||||
ts_return_type = "NlResult[]"
|
||||
)]
|
||||
pub fn nl_tokenize_batch(
|
||||
requests: Vec<NlTokenizeRequest>,
|
||||
root_type: Value,
|
||||
strict: Option<bool>,
|
||||
) -> napi::Result<Vec<Value>> {
|
||||
use zen_expression::intellisense::IntelliSense;
|
||||
use zen_expression::nl::NlRequest;
|
||||
|
||||
let root = json_to_variable_type(&root_type);
|
||||
let core_requests: Vec<NlRequest> = 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;
|
||||
|
||||
|
||||
@@ -11,4 +11,5 @@ mod loader;
|
||||
mod mt;
|
||||
mod policy;
|
||||
mod safe_result;
|
||||
mod slot;
|
||||
mod types;
|
||||
|
||||
@@ -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<String>,
|
||||
#[napi(ts_type = "PolicyCursorTarget")]
|
||||
pub target: Option<Value>,
|
||||
pub expr_code: Option<String>,
|
||||
pub args: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub info: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
@@ -296,6 +308,7 @@ pub struct PolicyCompletion {
|
||||
pub kind: String,
|
||||
pub detail: String,
|
||||
pub info: String,
|
||||
pub follow: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
@@ -318,6 +331,7 @@ pub struct PolicyRenameRequest {
|
||||
#[napi(ts_type = "PolicyRenameTarget")]
|
||||
pub target: Value,
|
||||
pub new_name: String,
|
||||
pub origin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<std::string::String, Value> = 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<ResolverRef>,
|
||||
}
|
||||
|
||||
@@ -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<Vec<Value>> {
|
||||
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<Option<Value>> {
|
||||
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<Vec<Value>> {
|
||||
pub fn references(
|
||||
&self,
|
||||
env: Env,
|
||||
target: Value,
|
||||
origin: Option<String>,
|
||||
) -> napi::Result<Vec<Value>> {
|
||||
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())
|
||||
|
||||
@@ -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<Option<Value>> {
|
||||
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<Vec<Value>> {
|
||||
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<Value>,
|
||||
#[napi(ts_type = "Record<string, Record<string, string>> | null")]
|
||||
pub labels: Option<Value>,
|
||||
}
|
||||
|
||||
#[napi(ts_return_type = "Array<{ id: string; result: PolicySlotResponse }>")]
|
||||
pub fn slot_batch(requests: Vec<SlotRequest>, strict: Option<bool>) -> napi::Result<Vec<Value>> {
|
||||
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<SlotRole> {
|
||||
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<LabelResolver> {
|
||||
let map: HashMap<String, HashMap<String, String>> =
|
||||
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<Value> {
|
||||
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<T: serde::Serialize>(value: &T) -> napi::Result<Value> {
|
||||
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<String, Value>> {
|
||||
value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| napi::Error::from_reason("expected a JSON object"))
|
||||
}
|
||||
|
||||
fn type_json(t: &Option<VariableType>) -> Value {
|
||||
t.as_ref().map(variable_type_to_json).unwrap_or(Value::Null)
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<str>,
|
||||
block_id: &Arc<str>,
|
||||
scope: &VariableType,
|
||||
is: &mut IntelliSense,
|
||||
) -> Vec<NlExpression> {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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<RefCell<IntelliSense>>;
|
||||
@@ -59,6 +59,7 @@ pub struct ExecutionError {
|
||||
|
||||
pub type SharedDictionaryTypes = Rc<ahash::HashMap<Arc<str>, VariableType>>;
|
||||
pub type SharedPoisonedPaths = Rc<RefCell<ahash::HashSet<Arc<str>>>>;
|
||||
pub type SharedDeclaredPaths = Rc<DataModelPaths>;
|
||||
|
||||
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<CursorTarget>,
|
||||
}
|
||||
|
||||
impl AnalysisContext {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
scope: VariableType,
|
||||
policy_path: Arc<str>,
|
||||
@@ -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<R>(
|
||||
&mut self,
|
||||
target: Option<CursorTarget>,
|
||||
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<str>,
|
||||
@@ -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<Arc<str>>,
|
||||
span: Option<(u32, u32)>,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
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));
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
}
|
||||
|
||||
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<str>,
|
||||
block_id: &Arc<str>,
|
||||
scope: &VariableType,
|
||||
is: &mut IntelliSense,
|
||||
dictionaries: &HashMap<Arc<str>, VariableType>,
|
||||
) -> Vec<NlExpression> {
|
||||
let mut out = Vec::new();
|
||||
let mut input_scopes: HashMap<Arc<str>, (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<Arc<str>, VariableType>,
|
||||
) -> (ExpressionKind, VariableType, Option<VariableType>) {
|
||||
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,
|
||||
|
||||
@@ -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<CursorTarget> {
|
||||
(!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<str>,
|
||||
block_id: &Arc<str>,
|
||||
scope: &VariableType,
|
||||
is: &mut IntelliSense,
|
||||
) -> Vec<NlExpression> {
|
||||
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<str>, ExpressionKind, VariableType)> {
|
||||
match &cursor.target {
|
||||
CursorTarget::ExpressionKey => {
|
||||
CursorTarget::ExpressionKey { .. } => {
|
||||
Some((self.key.clone(), ExpressionKind::Standard, scope))
|
||||
}
|
||||
CursorTarget::Expression { .. } => {
|
||||
|
||||
@@ -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<str>,
|
||||
block_id: &Arc<str>,
|
||||
scope: &VariableType,
|
||||
is: &mut IntelliSense,
|
||||
) -> Vec<NlExpression> {
|
||||
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,
|
||||
|
||||
@@ -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<str>, ExpressionKind, VariableType)> {
|
||||
self.kind.resolve_cursor(cursor, scope)
|
||||
}
|
||||
|
||||
pub fn nl(
|
||||
&self,
|
||||
policy_path: &Arc<str>,
|
||||
scope: &VariableType,
|
||||
is: &mut IntelliSense,
|
||||
dictionaries: &HashMap<Arc<str>, VariableType>,
|
||||
) -> Vec<NlExpression> {
|
||||
self.kind.nl(policy_path, &self.id, scope, is, dictionaries)
|
||||
}
|
||||
|
||||
pub fn nl_scope(
|
||||
&self,
|
||||
cursor: &Cursor,
|
||||
scope: VariableType,
|
||||
is: &mut IntelliSense,
|
||||
dictionaries: &HashMap<Arc<str>, VariableType>,
|
||||
) -> (ExpressionKind, VariableType, Option<VariableType>) {
|
||||
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<str>,
|
||||
block_id: &Arc<str>,
|
||||
scope: &VariableType,
|
||||
is: &mut IntelliSense,
|
||||
dictionaries: &HashMap<Arc<str>, VariableType>,
|
||||
) -> Vec<NlExpression> {
|
||||
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<str>>, Arc<str>)> {
|
||||
match self {
|
||||
BlockKind::DecisionTable(d) => d.write_keys(),
|
||||
|
||||
@@ -570,14 +570,15 @@ impl<'a> Driver<'a> {
|
||||
iter_path: &Arc<str>,
|
||||
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<Variable> = arr.borrow().iter().map(|v| v.shallow_clone()).collect();
|
||||
let single = target.as_object().is_some();
|
||||
let instances: Vec<Variable> = 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(),
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RuleEnrichedAnalysis>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
base_fields: HashMap<Rc<str>, VariableType>,
|
||||
owned: RefCell<Vec<VariableType>>,
|
||||
write_log: Vec<(PropertyPath, VariableType)>,
|
||||
own_writes: HashMap<BlockRef, std::ops::Range<usize>>,
|
||||
dependents: HashMap<BlockRef, HashSet<BlockRef>>,
|
||||
block_scopes: RefCell<HashMap<BlockRef, VariableType>>,
|
||||
}
|
||||
|
||||
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<usize>> = 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<BlockRef> {
|
||||
let mut seen: HashSet<BlockRef> = 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<PropertyPath, NodeIndex>,
|
||||
}
|
||||
|
||||
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<NodeIndex> = 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<BlockRef, HashSet<BlockRef>> {
|
||||
let mut out: HashMap<BlockRef, HashSet<BlockRef>> = 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<str>,
|
||||
@@ -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<RefCell<Vec<VariableType>>>,
|
||||
graph: &DependencyGraph,
|
||||
order: &[PropertyPath],
|
||||
rule_by_ref: &HashMap<BlockRef, Arc<Block>>,
|
||||
@@ -546,8 +694,17 @@ impl Snapshot {
|
||||
members: &HashSet<Arc<str>>,
|
||||
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<VariableType> = Vec::new();
|
||||
let mut own_writes: HashMap<BlockRef, std::ops::Range<usize>> = HashMap::new();
|
||||
let mut per_rule: Vec<RuleEnrichedAnalysis> = Vec::new();
|
||||
let mut diagnostics: Vec<Diagnostic> = 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<PropertyPath>,
|
||||
optional: HashSet<PropertyPath>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -192,7 +192,8 @@ pub struct Db {
|
||||
function_requests: RefCell<Vec<FunctionResolutionRequest>>,
|
||||
function_requested: RefCell<HashSet<FunctionKey>>,
|
||||
function_resolver: RefCell<Option<Box<FunctionTypeResolver>>>,
|
||||
scope_roots: RefCell<Vec<VariableType>>,
|
||||
scope_roots: Rc<RefCell<Vec<VariableType>>>,
|
||||
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<DataModelEntry> = subset
|
||||
.iter()
|
||||
|
||||
+192
-133
@@ -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<InspectResult> {
|
||||
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<Completion> {
|
||||
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<NlExpression> {
|
||||
if self.is_graph(policy) {
|
||||
return self.graph_nl(policy);
|
||||
}
|
||||
let policy_arc: Arc<str> = 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<NlResult> {
|
||||
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<zen_expression::intellisense::NlLabelResolver> {
|
||||
let mut labels: HashMap<Arc<str>, HashMap<Arc<str>, Arc<str>>> = HashMap::new();
|
||||
let mut add = |name: Arc<str>, dict: &DictionaryIr| {
|
||||
let entries: HashMap<Arc<str>, Arc<str>> = 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<VariableType>)> {
|
||||
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<PrepareRename> {
|
||||
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<EngineEdit> {
|
||||
self.rename_from(target, new_name, None)
|
||||
}
|
||||
|
||||
pub fn rename_from(
|
||||
&self,
|
||||
target: &RenameTarget,
|
||||
new_name: &str,
|
||||
origin: Option<&str>,
|
||||
) -> Vec<EngineEdit> {
|
||||
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<BlockRef, Vec<RenameSite>> = 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<EngineEdit> = 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<ReferenceSite> {
|
||||
self.references_from(target, None)
|
||||
}
|
||||
|
||||
pub fn references_from(
|
||||
&self,
|
||||
target: &RenameTarget,
|
||||
origin: Option<&str>,
|
||||
) -> Vec<ReferenceSite> {
|
||||
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<ReferenceSite> = 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<RenameSite>, Option<HashSet<Arc<str>>>) {
|
||||
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<Arc<str>> = 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<str>,
|
||||
mentioning: &HashSet<Arc<str>>,
|
||||
) -> HashSet<Arc<str>> {
|
||||
let closure = |policy: &Arc<str>| self.unit(policy).members.clone();
|
||||
let closures: HashMap<Arc<str>, HashSet<Arc<str>>> = mentioning
|
||||
.iter()
|
||||
.chain(std::iter::once(origin))
|
||||
.map(|p| (p.clone(), closure(p)))
|
||||
.collect();
|
||||
let mut scope: HashSet<Arc<str>> = 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<PrepareRename> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<VariableType>,
|
||||
pub row_types: HashMap<Arc<str>, VariableType>,
|
||||
pub nodes_scope: VariableType,
|
||||
pub branch_outputs: HashMap<Arc<str>, 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<Diagnostic>,
|
||||
@@ -72,6 +86,7 @@ pub(crate) struct GraphAnalyzer<'a> {
|
||||
content: &'a GraphContent,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
validate: bool,
|
||||
unchecked: bool,
|
||||
nodes_scope: VariableType,
|
||||
dictionary_types: HashMap<Arc<str>, 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<Arc<str>> = 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<Arc<str>, 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();
|
||||
|
||||
@@ -31,26 +31,44 @@ impl Db {
|
||||
) -> Option<(Arc<str>, 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
|
||||
|
||||
@@ -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<WriterHit> = Vec::new();
|
||||
for node in &content.nodes {
|
||||
@@ -173,6 +174,7 @@ impl Db {
|
||||
match &node.kind {
|
||||
DecisionNodeKind::ExpressionNode { content } => {
|
||||
let mut row_ids: Vec<Arc<str>> = 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 {
|
||||
|
||||
@@ -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<str> = Arc::from(global.join("."));
|
||||
let mut visited: HashSet<Arc<str>> = 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<PrepareRename> {
|
||||
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<str> = Arc::from(global.join("."));
|
||||
let mut visited: HashSet<Arc<str>> = 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<str>,
|
||||
@@ -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<str>,
|
||||
new_name: &str,
|
||||
) -> Vec<EngineEdit> {
|
||||
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<str>,
|
||||
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<Span> {
|
||||
let chars: Vec<char> = source.chars().collect();
|
||||
let units = utf16_prefix(&chars);
|
||||
let name_chars: Vec<char> = name.chars().collect();
|
||||
let needle: Vec<char> = "$nodes[".chars().collect();
|
||||
let mut out: Vec<Span> = 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<char> = 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<str>, path: &Arc<str>) -> Vec<RenameSite> {
|
||||
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<str>,
|
||||
path: &Arc<str>,
|
||||
) -> Vec<(Arc<str>, Arc<str>)> {
|
||||
let snap = self.snapshot();
|
||||
let mut out: Vec<(Arc<str>, Arc<str>)> = vec![(document.clone(), path.clone())];
|
||||
let mut visited: HashSet<(Arc<str>, Arc<str>)> = out.iter().cloned().collect();
|
||||
let mut queue: VecDeque<(Arc<str>, Arc<str>)> =
|
||||
VecDeque::from([(document.clone(), path.clone())]);
|
||||
while let Some((doc_path, doc_prop)) = queue.pop_front() {
|
||||
let mut next: Vec<(Arc<str>, Arc<str>)> = 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<str> = 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<str>, 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<str>, Arc<str>)> {
|
||||
let snap = self.snapshot();
|
||||
let mut out: Vec<(Arc<str>, Arc<str>)> = 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<str> = 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<str>,
|
||||
path: &Arc<str>,
|
||||
) -> Option<RenameTarget> {
|
||||
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<str>, local: &[&str]) -> Option<RenameTarget> {
|
||||
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<RenameSite> {
|
||||
pub(crate) fn policy_caller_sites(
|
||||
&self,
|
||||
target: &RenameTarget,
|
||||
scope: Option<&HashSet<Arc<str>>>,
|
||||
) -> Vec<RenameSite> {
|
||||
let segments: Vec<Arc<str>> = 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<str> = 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<RenameSite> = Vec::new();
|
||||
let mut emit = |node_id: &Arc<str>,
|
||||
expression_id: Option<Arc<str>>,
|
||||
@@ -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<Span> {
|
||||
let chars: Vec<char> = source.chars().collect();
|
||||
let units = utf16_prefix(&chars);
|
||||
let mask = js_code_mask(&chars);
|
||||
let needle_chars: Vec<char> = needle.chars().collect();
|
||||
let mut out: Vec<Span> = 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<u32> {
|
||||
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<bool> {
|
||||
#[derive(PartialEq)]
|
||||
enum State {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<NlExpression> {
|
||||
let path_arc: Arc<str> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<str>) -> Option<Arc<GraphAnalysis>> {
|
||||
@@ -188,21 +185,6 @@ impl Db {
|
||||
properties
|
||||
}
|
||||
|
||||
pub(crate) fn graph_cell_expected(&self, cursor: &Cursor) -> Option<VariableType> {
|
||||
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<Arc<str>> {
|
||||
let path_arc: Arc<str> = Arc::from(path);
|
||||
let Some(analysis) = self.graph_analysis(&path_arc) else {
|
||||
|
||||
@@ -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<NlExpression> {
|
||||
self.db.nl(policy_path)
|
||||
pub fn cursor_scope(&self, cursor: &Cursor) -> Option<CursorScope> {
|
||||
self.db.cursor_scope(cursor)
|
||||
}
|
||||
|
||||
pub fn nl_tokenize(&self, cursor: &Cursor, text: &str) -> Option<NlResult> {
|
||||
self.db.nl_tokenize(cursor, text)
|
||||
pub fn slot(&self, cursor: &Cursor, text: &str) -> Option<SlotResponse> {
|
||||
self.db.slot(cursor, text)
|
||||
}
|
||||
|
||||
pub fn facts(&self, policy_path: &str) -> Vec<ExpressionFacts> {
|
||||
self.db.facts(policy_path)
|
||||
}
|
||||
|
||||
pub fn prepare_rename(&self, cursor: &Cursor) -> Option<PrepareRename> {
|
||||
@@ -205,6 +210,23 @@ impl Workspace {
|
||||
self.db.references(target)
|
||||
}
|
||||
|
||||
pub fn rename_from(
|
||||
&self,
|
||||
target: &RenameTarget,
|
||||
new_name: &str,
|
||||
origin: Option<&str>,
|
||||
) -> Vec<EngineEdit> {
|
||||
self.db.rename_from(target, new_name, origin)
|
||||
}
|
||||
|
||||
pub fn references_from(
|
||||
&self,
|
||||
target: &RenameTarget,
|
||||
origin: Option<&str>,
|
||||
) -> Vec<ReferenceSite> {
|
||||
self.db.references_from(target, origin)
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str, limit: Option<u32>) -> Vec<SearchHit> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<str>,
|
||||
pub target: CursorTarget,
|
||||
pub source: Arc<str>,
|
||||
pub kind: ExpressionKind,
|
||||
pub role: SlotRole,
|
||||
pub subject_type: Option<VariableType>,
|
||||
pub expected_type: Option<VariableType>,
|
||||
#[serde(flatten)]
|
||||
pub literals: Literals,
|
||||
pub subject_options: Vec<ValueOption>,
|
||||
}
|
||||
|
||||
struct Site {
|
||||
block_id: Arc<str>,
|
||||
target: CursorTarget,
|
||||
source: Arc<str>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn facts(&self, policy: &str) -> Vec<ExpressionFacts> {
|
||||
let path: Arc<str> = 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<str>) -> Vec<Site> {
|
||||
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<str> = Arc::from(block_id);
|
||||
let mut push = |target: CursorTarget, source: &Arc<str>| {
|
||||
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<str> = 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<str>) -> Vec<Site> {
|
||||
let snap = self.snapshot();
|
||||
let Some(content) = snap.graphs.get(path).and_then(|doc| doc.as_graph()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let empty: Arc<str> = Arc::from("");
|
||||
content
|
||||
.nodes
|
||||
.iter()
|
||||
.flat_map(|node| {
|
||||
let mut sites: Vec<Site> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<VariableType>,
|
||||
pub expected_type: Option<VariableType>,
|
||||
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<Arc<str>, HashMap<Arc<str>, Arc<str>>>;
|
||||
|
||||
enum LabelKey {
|
||||
Unit(Arc<Unit>),
|
||||
Graph(Arc<GraphAnalysis>),
|
||||
}
|
||||
|
||||
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<Arc<LabelMap>>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LabelCache {
|
||||
entries: RefCell<HashMap<Arc<str>, LabelEntry>>,
|
||||
}
|
||||
|
||||
impl LabelCache {
|
||||
fn label_map(
|
||||
dictionaries: impl Iterator<Item = (Arc<str>, Arc<DictionaryIr>)>,
|
||||
) -> Option<Arc<LabelMap>> {
|
||||
let mut labels = LabelMap::new();
|
||||
for (name, dict) in dictionaries {
|
||||
let entries: HashMap<Arc<str>, Arc<str>> = 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<SlotResponse> {
|
||||
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<LabelResolver> {
|
||||
let key = if self.is_graph(policy) {
|
||||
let path: Arc<str> = 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<VariableType>,
|
||||
/// `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<VariableType>) -> 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<VariableType> {
|
||||
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<VariableType> {
|
||||
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<VariableType> {
|
||||
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<CursorScope> {
|
||||
self.cursor_scope_cached(cursor, &mut siblings::SiblingCache::default())
|
||||
}
|
||||
|
||||
fn cursor_scope_cached(
|
||||
&self,
|
||||
cursor: &Cursor,
|
||||
cache: &mut siblings::SiblingCache,
|
||||
) -> Option<CursorScope> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CursorScope> {
|
||||
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<CursorScope> {
|
||||
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<VariableType> {
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let kind = CursorScope::known_type(self.enriched_of_unit(unit).declared_at(path))?;
|
||||
let segments: Vec<Rc<str>> = 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<VariableType> {
|
||||
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<CursorScope> {
|
||||
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<CursorScope> {
|
||||
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<VariableType> {
|
||||
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<VariableType> = 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<str>,
|
||||
scope: &VariableType,
|
||||
) -> VariableType {
|
||||
IntelliSenseSource::analyze(
|
||||
&mut is.borrow_mut(),
|
||||
source,
|
||||
ExpressionKind::Standard,
|
||||
scope,
|
||||
)
|
||||
.return_type
|
||||
.shallow_clone()
|
||||
}
|
||||
}
|
||||
@@ -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<str>, Arc<str>, Arc<str>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SiblingCache {
|
||||
columns: HashMap<ColumnKey, Option<ColumnTypes>>,
|
||||
}
|
||||
|
||||
struct ColumnTypes {
|
||||
distinct: Vec<(VariableType, usize)>,
|
||||
rows: HashMap<Arc<str>, usize>,
|
||||
}
|
||||
|
||||
impl ColumnTypes {
|
||||
const MAX_DISTINCT: usize = 64;
|
||||
|
||||
fn collect(cells: impl IntoIterator<Item = (Arc<str>, Option<VariableType>)>) -> Option<Self> {
|
||||
let mut distinct: Vec<(VariableType, usize)> = Vec::new();
|
||||
let mut index_of: HashMap<String, usize> = 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<usize>) -> Option<VariableType> {
|
||||
self.distinct
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| Some(*index) != skip)
|
||||
.map(|(_, (value, _))| value)
|
||||
.fold(None, |acc: Option<VariableType>, value| {
|
||||
Some(match acc {
|
||||
Some(acc) => acc.merge(value),
|
||||
None => value.shallow_clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn excluding(&self, row: &Arc<str>) -> Option<VariableType> {
|
||||
let own = self
|
||||
.rows
|
||||
.get(row)
|
||||
.copied()
|
||||
.filter(|index| self.distinct[*index].1 == 1);
|
||||
self.union_without(own)
|
||||
}
|
||||
}
|
||||
|
||||
impl SiblingCache {
|
||||
pub(super) fn infer<I>(
|
||||
&mut self,
|
||||
cursor: &Cursor,
|
||||
analyze: impl FnOnce() -> I,
|
||||
) -> Option<VariableType>
|
||||
where
|
||||
I: IntoIterator<Item = (Arc<str>, Option<VariableType>)>,
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<str> },
|
||||
Expression {
|
||||
id: Arc<str>,
|
||||
},
|
||||
AssertionOutput,
|
||||
ExpressionKey,
|
||||
ExpressionKey {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
id: Option<Arc<str>>,
|
||||
},
|
||||
MatchTarget,
|
||||
MatchValue { id: Arc<str> },
|
||||
DecisionTableHead { col: Arc<str> },
|
||||
DecisionTableCell { row: Arc<str>, col: Arc<str> },
|
||||
MatchValue {
|
||||
id: Arc<str>,
|
||||
},
|
||||
DecisionTableHead {
|
||||
col: Arc<str>,
|
||||
},
|
||||
DecisionTableCell {
|
||||
row: Arc<str>,
|
||||
col: Arc<str>,
|
||||
},
|
||||
DataModelName,
|
||||
DataModelProperty { id: Arc<str> },
|
||||
DataModelProperty {
|
||||
id: Arc<str>,
|
||||
},
|
||||
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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub info: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<str>,
|
||||
pub block_id: Arc<str>,
|
||||
pub target: CursorTarget,
|
||||
pub kind: ExpressionKind,
|
||||
pub source: String,
|
||||
pub result: NlResult,
|
||||
}
|
||||
|
||||
impl NlExpression {
|
||||
pub(crate) fn project(
|
||||
is: &mut IntelliSense,
|
||||
policy_path: &Arc<str>,
|
||||
block_id: &Arc<str>,
|
||||
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<str>,
|
||||
block_id: &Arc<str>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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\""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
+151
-3
@@ -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<Option<String>> {
|
||||
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<String> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<NlTokenKind> {
|
||||
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<NlTokenKind> = 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<NlTokenKind> = 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,
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
graphs: Vec<String>,
|
||||
test: Vec<CompletionsCase>,
|
||||
}
|
||||
|
||||
@@ -329,6 +342,8 @@ struct CompletionsCase {
|
||||
pos: u32,
|
||||
#[serde(default)]
|
||||
head: bool,
|
||||
#[serde(default)]
|
||||
key: bool,
|
||||
row: Option<String>,
|
||||
#[serde(default)]
|
||||
includes: Vec<String>,
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
graphs: Vec<String>,
|
||||
test: Vec<SlotsCase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SlotsCase {
|
||||
name: String,
|
||||
policy: String,
|
||||
block_id: String,
|
||||
target: String,
|
||||
id: Option<String>,
|
||||
row: Option<String>,
|
||||
kind: Option<String>,
|
||||
role: Option<String>,
|
||||
expected: Option<String>,
|
||||
subject: Option<String>,
|
||||
text: Option<String>,
|
||||
state: Option<String>,
|
||||
options: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
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<VariableType>| 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<String> = 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<String>,
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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<String> = 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<String> = 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}");
|
||||
}
|
||||
|
||||
@@ -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>) -> Value {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](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;
|
||||
|
||||
@@ -197,6 +197,7 @@ impl FunctionDefinition for CompositeFunction {
|
||||
.collect::<Vec<_>>()
|
||||
.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<String> = possible_types
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>()
|
||||
.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::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.filter(|t| seen.insert(t.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ")
|
||||
}
|
||||
|
||||
@@ -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<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub method_for: Option<VariableType>,
|
||||
#[serde(skip)]
|
||||
pub var_type: Option<VariableType>,
|
||||
#[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<Completion> {
|
||||
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::<Vec<_>>();
|
||||
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<str>, VariableType)],
|
||||
) -> Vec<Completion> {
|
||||
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<Completion> {
|
||||
pub fn build_scope(data: &VariableType, locals: &[(Rc<str>, VariableType)]) -> Vec<Completion> {
|
||||
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<Rc<str>, VariableType>) -> Vec<(&Rc<str>, &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<Completion> {
|
||||
FunctionKind::try_from(name)
|
||||
.ok()
|
||||
.map(|fk| Self::function(fk, None))
|
||||
}
|
||||
|
||||
pub(crate) fn method_named(name: &str) -> Option<Completion> {
|
||||
MethodKind::try_from(name).ok().map(Self::method)
|
||||
}
|
||||
|
||||
fn function(fk: FunctionKind, boost_override: Option<i32>) -> 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"],
|
||||
|
||||
@@ -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<Rc<str>>,
|
||||
spans: Vec<(u32, u32)>,
|
||||
collection: Vec<Rc<str>>,
|
||||
) {
|
||||
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(
|
||||
|
||||
@@ -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<Operator>,
|
||||
) -> (&'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<Diagnostic>) {
|
||||
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<Operator>)> = 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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub info: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn inspect_at(
|
||||
source: &str,
|
||||
pos: u32,
|
||||
tokens: &[IntelliSenseToken],
|
||||
) -> Option<InspectionResult> {
|
||||
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<InspectionResult> {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Diagnostic>,
|
||||
}
|
||||
|
||||
pub type NlLabelResolver = Rc<dyn Fn(&str, &str) -> Option<String>>;
|
||||
pub use crate::slot::LabelResolver;
|
||||
|
||||
pub struct IntelliSense {
|
||||
arena: Bump,
|
||||
lexer: Lexer,
|
||||
strict: bool,
|
||||
nl_labels: Option<NlLabelResolver>,
|
||||
pub(crate) arena: Bump,
|
||||
pub(crate) lexer: Lexer,
|
||||
pub(crate) strict: bool,
|
||||
pub(crate) labels: Option<LabelResolver>,
|
||||
}
|
||||
|
||||
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<NlLabelResolver>) {
|
||||
self.nl_labels = labels;
|
||||
pub fn set_labels(&mut self, labels: Option<LabelResolver>) {
|
||||
self.labels = labels;
|
||||
}
|
||||
|
||||
pub fn completions(
|
||||
@@ -87,22 +86,70 @@ impl IntelliSense {
|
||||
pos: u32,
|
||||
data: &VariableType,
|
||||
) -> Vec<completion::Completion> {
|
||||
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<InspectionResult> {
|
||||
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<InspectionResult> {
|
||||
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<ExpressionAnalysis> {
|
||||
@@ -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<NlResult> {
|
||||
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<Vec<crate::nl::EnumOption>> {
|
||||
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<T>(
|
||||
&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 {
|
||||
|
||||
@@ -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<Rc<VariableType>> for TypeInfo {
|
||||
#[derive(Debug)]
|
||||
pub struct TypesProvider {
|
||||
types: HashMap<usize, TypeInfo>,
|
||||
codes: HashMap<usize, ErrorCode>,
|
||||
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<ErrorCode>)) -> 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<Vec<Rc<str>>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_enum_comparison(left: &VariableType, right: &VariableType) -> Option<Option<String>> {
|
||||
fn check_enum_comparison(
|
||||
left: &VariableType,
|
||||
right: &VariableType,
|
||||
) -> Option<Option<(String, Option<ErrorCode>)>> {
|
||||
let (left, _) = left.unwrap_nullable();
|
||||
let (right, _) = right.unwrap_nullable();
|
||||
|
||||
@@ -732,8 +777,15 @@ fn check_enum_comparison(left: &VariableType, right: &VariableType) -> Option<Op
|
||||
(VariableType::Enum(_, _), _) => 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<Op
|
||||
if c1 == c2 {
|
||||
Some(None)
|
||||
} else {
|
||||
Some(Some(format!(
|
||||
"Value `\"{c1}\"` will never equal `\"{c2}\"`."
|
||||
Some(Some((
|
||||
format!("Value `\"{c1}\"` will never equal `\"{c2}\"`."),
|
||||
None,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ pub struct Lexer {
|
||||
capacity_hint: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LenientTokens<'arena> {
|
||||
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<LenientTokens<'arena>> {
|
||||
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<CursorItem> {
|
||||
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<usize> = 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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
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<str>],
|
||||
labels: Option<&NlLabelResolver>,
|
||||
) -> Vec<EnumOption> {
|
||||
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<Vec<EnumOption>> {
|
||||
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<VariableType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NlResult {
|
||||
pub id: String,
|
||||
pub tokens: Vec<NlToken>,
|
||||
pub enums: Vec<Vec<EnumOption>>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
#[serde(skip)]
|
||||
pub subject_type: Option<VariableType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subject_options: Option<Vec<EnumOption>>,
|
||||
}
|
||||
@@ -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<Box<str>>,
|
||||
elide: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Projector<'a> {
|
||||
source: &'a str,
|
||||
types: &'a TypesProvider,
|
||||
metadata: &'a AstMetadata,
|
||||
unary: bool,
|
||||
labels: Option<NlLabelResolver>,
|
||||
aliases: Vec<AliasScope>,
|
||||
pending_elide: bool,
|
||||
pending_implied: bool,
|
||||
enums: Vec<Vec<EnumOption>>,
|
||||
out: Vec<NlToken>,
|
||||
}
|
||||
|
||||
impl<'a> Projector<'a> {
|
||||
pub(crate) fn new(
|
||||
source: &'a str,
|
||||
types: &'a TypesProvider,
|
||||
metadata: &'a AstMetadata,
|
||||
unary: bool,
|
||||
labels: Option<NlLabelResolver>,
|
||||
) -> 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<VariableType>,
|
||||
) -> (Vec<NlToken>, Vec<Vec<EnumOption>>) {
|
||||
self.project(root, expected);
|
||||
(self.out, self.enums)
|
||||
}
|
||||
|
||||
fn project(&mut self, node: &Node, expected: Option<VariableType>) {
|
||||
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<OpSym> {
|
||||
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<str> {
|
||||
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<VariableType> {
|
||||
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<Box<str>>) -> Vec<Box<str>> {
|
||||
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<VariableType>,
|
||||
) {
|
||||
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<EditHint> {
|
||||
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<VariableType>, Option<VariableType>) {
|
||||
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<EditHint>) {
|
||||
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<str>]) -> 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<Rc<str>>, Vec<Rc<str>>)> {
|
||||
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<VariableType> {
|
||||
match ty? {
|
||||
VariableType::Array(inner) | VariableType::Nullable(inner) => {
|
||||
Self::item_expectation(Some(inner))
|
||||
}
|
||||
other => Some(other.shallow_clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn field_path(node: &Node) -> Option<Vec<Box<str>>> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<EditHint>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "t", rename_all = "camelCase")]
|
||||
pub enum NlTokenKind {
|
||||
GroupOpen,
|
||||
GroupClose,
|
||||
ListOpen,
|
||||
ListClose,
|
||||
Comma,
|
||||
EnumList {
|
||||
selected: Vec<Box<str>>,
|
||||
},
|
||||
|
||||
Context,
|
||||
Root,
|
||||
Null,
|
||||
|
||||
Field {
|
||||
path: Vec<Box<str>>,
|
||||
ty: TypeTag,
|
||||
},
|
||||
Element {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
alias: Option<Box<str>>,
|
||||
},
|
||||
|
||||
Number {
|
||||
value: Box<str>,
|
||||
},
|
||||
Str {
|
||||
value: Box<str>,
|
||||
},
|
||||
Bool {
|
||||
value: bool,
|
||||
},
|
||||
|
||||
Op {
|
||||
sym: OpSym,
|
||||
implied: bool,
|
||||
between: bool,
|
||||
},
|
||||
Word {
|
||||
sym: WordSym,
|
||||
},
|
||||
Assign,
|
||||
StmtEnd,
|
||||
Func {
|
||||
sym: Box<str>,
|
||||
closure: bool,
|
||||
},
|
||||
Method {
|
||||
sym: Box<str>,
|
||||
},
|
||||
|
||||
TemplateOpen,
|
||||
TemplateText {
|
||||
value: Box<str>,
|
||||
},
|
||||
TemplateClose,
|
||||
|
||||
IntervalOpen {
|
||||
inclusive: bool,
|
||||
},
|
||||
IntervalClose {
|
||||
inclusive: bool,
|
||||
},
|
||||
|
||||
Code {
|
||||
source: Box<str>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<TypeTag> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum EditHint {
|
||||
DatePicker,
|
||||
Select {
|
||||
options: u32,
|
||||
},
|
||||
MultiSelect {
|
||||
options: u32,
|
||||
},
|
||||
OpSelect {
|
||||
options: Vec<OpChoice>,
|
||||
},
|
||||
QuantSelect {
|
||||
options: Vec<OpSym>,
|
||||
subject: Box<str>,
|
||||
list: Box<str>,
|
||||
},
|
||||
FuncSelect {
|
||||
options: Vec<Box<str>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpChoice {
|
||||
pub sym: OpSym,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
impl From<OpSym> 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<String>,
|
||||
}
|
||||
|
||||
#[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<Self> {
|
||||
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,
|
||||
}
|
||||
@@ -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) },
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<VariableType>,
|
||||
}
|
||||
|
||||
pub(crate) struct NodeTable<'a> {
|
||||
entries: Vec<Entry<'a>>,
|
||||
}
|
||||
|
||||
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<VariableType>)> =
|
||||
vec![(parsed.ast, root_expected)];
|
||||
|
||||
while let Some((node, expected)) = stack.pop() {
|
||||
let mut children: Vec<(&'a Node<'a>, Option<VariableType>)> = 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<VariableType> {
|
||||
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<Option<VariableType>> {
|
||||
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<VariableType> {
|
||||
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<EnumTable>,
|
||||
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<str>>, values: &[Rc<str>]) -> 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<LiteralFact> {
|
||||
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<Self> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<LiteralFact> {
|
||||
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<DateArg> {
|
||||
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<DateArg> {
|
||||
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<Vec<String>> {
|
||||
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<LiteralFact> = 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn(&str, &str) -> Option<String>>;
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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<VariableType>,
|
||||
pub operand: Option<VariableType>,
|
||||
pub options: Vec<ValueOption>,
|
||||
pub operators: Vec<&'static str>,
|
||||
pub function: Option<String>,
|
||||
pub argument: Option<u32>,
|
||||
pub replace_span: Span,
|
||||
pub in_string: Option<char>,
|
||||
pub listed: Vec<String>,
|
||||
pub auto_open: bool,
|
||||
pub locals: Vec<Local>,
|
||||
#[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<String>,
|
||||
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<String>,
|
||||
},
|
||||
Field {
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnumTable {
|
||||
pub name: Option<String>,
|
||||
pub options: Vec<ValueOption>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Literals {
|
||||
pub complete: bool,
|
||||
#[serde(rename = "literals")]
|
||||
pub facts: Vec<LiteralFact>,
|
||||
pub enums: Vec<EnumTable>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SlotResult {
|
||||
pub slot: Slot,
|
||||
pub literals: Literals,
|
||||
}
|
||||
|
||||
pub(crate) type EnumDomain = (Option<Rc<str>>, Vec<Rc<str>>);
|
||||
|
||||
pub trait VariableTypeSlot {
|
||||
fn innermost(&self) -> &VariableType;
|
||||
fn enum_domain(&self) -> Option<EnumDomain>;
|
||||
fn declared(self) -> Option<VariableType>;
|
||||
}
|
||||
|
||||
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<EnumDomain> {
|
||||
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<VariableType> {
|
||||
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<Vec<Self>> {
|
||||
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<str>],
|
||||
labels: Option<&LabelResolver>,
|
||||
) -> Vec<Self> {
|
||||
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<String> {
|
||||
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<VariableType>) -> 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<Self> {
|
||||
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<str>, 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<Self> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
args: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
label: Option<String>,
|
||||
kind: Option<String>,
|
||||
detail: Option<String>,
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = "*" } },
|
||||
]
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -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"'
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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 },
|
||||
] },
|
||||
]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<TestCase>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TestCase {
|
||||
expression: String,
|
||||
role: Option<String>,
|
||||
subject: Option<String>,
|
||||
scope: Option<String>,
|
||||
expected: Option<String>,
|
||||
slot: Option<ExpectedSlot>,
|
||||
literals: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
includes: Vec<String>,
|
||||
#[serde(default)]
|
||||
excludes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExpectedSlot {
|
||||
state: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
options: Option<Vec<String>>,
|
||||
operators: Option<Vec<String>>,
|
||||
span: Option<String>,
|
||||
auto_open: Option<bool>,
|
||||
}
|
||||
|
||||
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<VariableType> {
|
||||
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<String> {
|
||||
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<String> =
|
||||
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<String> {
|
||||
let state = serde_json::to_value(slot.state).unwrap();
|
||||
let options: Vec<String> = slot
|
||||
.options
|
||||
.iter()
|
||||
.map(|o| match o.label == o.value {
|
||||
true => o.value.clone(),
|
||||
false => format!("{}={}", o.value, o.label),
|
||||
})
|
||||
.collect();
|
||||
let operators: Vec<String> = 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<String> {
|
||||
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<String> = 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<String> = 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<String> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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<T> = Rc<RefCell<T>>;
|
||||
|
||||
#[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<Rc<str>, VariableType>,
|
||||
path: Vec<*const ()>,
|
||||
}
|
||||
|
||||
impl Serialize for VariableType {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
Guarded {
|
||||
inner: self,
|
||||
path: &[],
|
||||
}
|
||||
.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Guarded<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
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<Rc<str>, 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
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(<recursive>)");
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user