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:
stefan-gorules
2026-09-28 21:47:40 +02:00
committed by GitHub
parent a7157f3448
commit ffdfd807d1
89 changed files with 9549 additions and 4463 deletions
+83 -136
View File
@@ -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[];
}
/**
+105 -150
View File
@@ -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
+1 -2
View File
@@ -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
+1 -56
View File
@@ -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;
+1
View File
@@ -11,4 +11,5 @@ mod loader;
mod mt;
mod policy;
mod safe_result;
mod slot;
mod types;
+46 -68
View File
@@ -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())
+149
View File
@@ -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)
}
-11
View File
@@ -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' });
});
});
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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
+1 -29
View File
@@ -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,
}
+40 -6
View File
@@ -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));
+43 -138
View File
@@ -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,
+6 -29
View File
@@ -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 { .. } => {
+6 -50
View File
@@ -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,
+4 -44
View File
@@ -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(),
+11 -7
View File
@@ -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(),
+7 -6
View File
@@ -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;
+181 -5
View File
@@ -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(&current) {
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>,
+63
View File
@@ -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)
+23 -14
View File
@@ -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
View File
@@ -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[&current].contains(other) || closures[other].contains(&current);
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
}
+84 -37
View File
@@ -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 -10
View File
@@ -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
+17 -12
View File
@@ -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 {
+234 -25
View File
@@ -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(&quote)
{
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 {
+1 -1
View File
@@ -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};
-60
View File
@@ -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
}
}
+1 -19
View File
@@ -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 {
+32 -10
View File
@@ -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)
}
}
+232
View File
@@ -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()
}
}
+302
View File
@@ -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)
}
}
}
+390
View File
@@ -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)
}
}
+26 -6
View File
@@ -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)]
+46 -18
View File
@@ -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(),
}
}
+4 -4
View File
@@ -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,
-62
View File
@@ -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,
}
}
}
+30 -1
View File
@@ -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"
}
]
}
}
}
]
}
+382
View File
@@ -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
View File
@@ -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 -234
View File
@@ -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();
-231
View File
@@ -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 -83
View File
@@ -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 {
+142 -3
View File
@@ -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"));
}
+437 -70
View File
@@ -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",
+1 -1
View File
@@ -5,7 +5,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![crates.io](https://img.shields.io/crates/v/zen-expression.svg)](https://crates.io/crates/zen-expression)
ZEN expressions are readable by business users and fast enough for hot paths: a complete language for conditions, calculations and data transformation, with lexer, parser, compiler, VM, type checking and natural-language rendering included in this crate.
ZEN expressions are readable by business users and fast enough for hot paths: a complete language for conditions, calculations and data transformation, with lexer, parser, compiler, VM, type checking and editor completions included in this crate.
```rust
use serde_json::json;
+5 -4
View File
@@ -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(" | ")
}
+116 -11
View File
@@ -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"],
+31 -15
View File
@@ -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(
+202 -29
View File
@@ -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,
)
}
+92 -18
View File
@@ -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)
)
}
}
+65 -161
View File
@@ -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,
)))
}
}
+91 -14
View File
@@ -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(),
});
+2 -2
View File
@@ -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::*;
+1 -1
View File
@@ -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;
-73
View File
@@ -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>>,
}
-951
View File
@@ -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,
}
}
}
-247
View File
@@ -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,
}
+13 -1
View File
@@ -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) },
}
+1 -1
View File
@@ -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;
+3 -6
View File
@@ -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()),
});
};
+1 -2
View File
@@ -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
+647
View File
@@ -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,
}
}
}
+500
View File
@@ -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,
}
}
}
+61
View File
@@ -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()
}
}
+42 -8
View File
@@ -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 },
] },
]
+588
View File
@@ -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
+328
View File
@@ -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:?}");
}
}
+185 -2
View File
@@ -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 {
+70 -25
View File
@@ -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();