From 2e4ab54407227b95a2c33097f8803920261da2dd Mon Sep 17 00:00:00 2001 From: Sindre Gulseth Date: Fri, 26 Jul 2024 00:14:38 +0200 Subject: [PATCH 1/4] feat(typeEvaluator): remove resolve condition, use walk to determine conditions --- src/typeEvaluator/functions.ts | 10 + src/typeEvaluator/matching.ts | 79 +++ src/typeEvaluator/typeEvaluate.ts | 629 ++++++++---------- src/typeEvaluator/typeHelpers.ts | 47 +- .../test/typeEvaluate.test.ts.test.cjs | 15 +- test/typeEvaluate.test.ts | 88 ++- test/typeEvaluateCompare.test.ts | 38 +- 7 files changed, 534 insertions(+), 372 deletions(-) create mode 100644 src/typeEvaluator/matching.ts diff --git a/src/typeEvaluator/functions.ts b/src/typeEvaluator/functions.ts index ac7b3999..e386bfbb 100644 --- a/src/typeEvaluator/functions.ts +++ b/src/typeEvaluator/functions.ts @@ -113,6 +113,16 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.defined': { return {type: 'boolean'} } + case 'global.path': { + const arg = walk({node: node.args[0], scope}) + return mapConcrete(arg, scope, (arg) => { + if (arg.type === 'string') { + return {type: 'string'} + } + + return {type: 'null'} + }) + } case 'global.coalesce': { if (node.args.length === 0) { return {type: 'null'} satisfies NullTypeNode diff --git a/src/typeEvaluator/matching.ts b/src/typeEvaluator/matching.ts new file mode 100644 index 00000000..92a185b0 --- /dev/null +++ b/src/typeEvaluator/matching.ts @@ -0,0 +1,79 @@ +import { + matchAnalyzePattern, + matchText, + matchTokenize, + type Pattern, + type Token, +} from '../evaluator/matching' +import type {ConcreteTypeNode} from './typeHelpers' + +export function match(left: ConcreteTypeNode, right: ConcreteTypeNode): boolean | undefined { + let tokens: Token[] = [] + let patterns: Pattern[] = [] + if (left.type === 'string') { + if (left.value === undefined) { + return undefined + } + tokens = tokens.concat(matchTokenize(left.value)) + } + if (left.type === 'array') { + if (left.of.type === 'unknown') { + return undefined + } + if (left.of.type === 'string') { + // eslint-disable-next-line max-depth + if (left.of.value === undefined) { + return undefined + } + + tokens = tokens.concat(matchTokenize(left.of.value)) + } + if (left.of.type === 'union') { + // eslint-disable-next-line max-depth + for (const node of left.of.of) { + // eslint-disable-next-line max-depth + if (node.type === 'string' && node.value !== undefined) { + tokens = tokens.concat(matchTokenize(node.value)) + } + } + } + } + + if (right.type === 'string') { + if (right.value === undefined) { + return undefined + } + patterns = patterns.concat(matchAnalyzePattern(right.value)) + } + if (right.type === 'array') { + if (right.of.type === 'unknown') { + return undefined + } + if (right.of.type === 'string') { + // eslint-disable-next-line max-depth + if (right.of.value === undefined) { + return undefined + } + patterns = patterns.concat(matchAnalyzePattern(right.of.value)) + } + if (right.of.type === 'union') { + // eslint-disable-next-line max-depth + for (const node of right.of.of) { + // eslint-disable-next-line max-depth + if (node.type === 'string') { + // eslint-disable-next-line max-depth + if (node.value === undefined) { + return undefined + } + patterns = patterns.concat(matchAnalyzePattern(node.value)) + } + + // eslint-disable-next-line max-depth + if (node.type !== 'string') { + return false + } + } + } + } + return matchText(tokens, patterns) +} diff --git a/src/typeEvaluator/typeEvaluate.ts b/src/typeEvaluator/typeEvaluate.ts index 16725cbd..e1a3f1ce 100644 --- a/src/typeEvaluator/typeEvaluate.ts +++ b/src/typeEvaluator/typeEvaluate.ts @@ -1,15 +1,9 @@ import debug from 'debug' -import { - matchAnalyzePattern, - matchText, - matchTokenize, - type Pattern, - type Token, -} from '../evaluator/matching' import type { AccessAttributeNode, AccessElementNode, + AndNode, ArrayCoerceNode, ArrayNode, DerefNode, @@ -23,7 +17,9 @@ import type { ObjectConditionalSplatNode, ObjectNode, ObjectSplatNode, + OpCall, OpCallNode, + OrNode, ParentNode, PosNode, ProjectionNode, @@ -32,8 +28,10 @@ import type { ValueNode, } from '../nodeTypes' import {handleFuncCallNode} from './functions' +import {match} from './matching' import {optimizeUnions} from './optimizations' import {Context, Scope} from './scope' +import {isFuncCall, mapConcrete, mapUnion, nullUnion, resolveInline} from './typeHelpers' import type { ArrayTypeNode, BooleanTypeNode, @@ -49,7 +47,6 @@ import type { UnionTypeNode, UnknownTypeNode, } from './types' -import {mapConcrete, nullUnion, resolveInline} from './typeHelpers' const $trace = debug('typeEvaluator:evaluate:trace') $trace.log = console.log.bind(console) // eslint-disable-line no-console @@ -223,7 +220,7 @@ function handleObjectNode(node: ObjectNode, scope: Scope): TypeNode { } if (attr.type === 'ObjectConditionalSplat') { - const condition = resolveCondition(attr.condition, scope) + const condition = booleanValue(walk({node: attr.condition, scope})) $trace('object.conditional.splat.condition %O', condition) // condition is never met, skip this attribute if (condition === false) { @@ -460,55 +457,151 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { $trace('opcall.node %O', node) const lhs = walk({node: node.left, scope}) const rhs = walk({node: node.right, scope}) - return mapConcrete(lhs, scope, (left) => - // eslint-disable-next-line complexity - mapConcrete(rhs, scope, (right) => { + return mapUnion(lhs, scope, (left) => + // eslint-disable-next-line complexity, max-statements + mapUnion(rhs, scope, (right) => { $trace('opcall.node.concrete "%s" %O', node.op, {left, right}) switch (node.op) { - case '==': + case '==': { + // == always returns a boolean, no matter the compared types. + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'boolean'} + } + if (left.type !== right.type) { + return { + type: 'boolean', + value: false, + } satisfies BooleanTypeNode + } + if (left.type === 'null') { + return { + type: 'boolean', + value: true, + } satisfies BooleanTypeNode + } + if (!isPrimitiveTypeNode(left) || !isPrimitiveTypeNode(right)) { + return { + type: 'boolean', + value: false, + } satisfies BooleanTypeNode + } + return { + type: 'boolean', + value: evaluateComparison(node.op, left, right), + } satisfies BooleanTypeNode + } case '!=': { + // != always returns a boolean, no matter the compared types. + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'boolean'} + } + if (left.type !== right.type) { + return { + type: 'boolean', + value: true, + } satisfies BooleanTypeNode + } + if (left.type === 'null') { + return { + type: 'boolean', + value: false, + } satisfies BooleanTypeNode + } + if (!isPrimitiveTypeNode(left) || !isPrimitiveTypeNode(right)) { + return { + type: 'boolean', + value: true, + } satisfies BooleanTypeNode + } + + let value = evaluateComparison('==', left, right) + if (value !== undefined) value = !value return { type: 'boolean', - value: resolveCondition(node, scope), + value, } satisfies BooleanTypeNode } case '>': case '>=': case '<': case '<=': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type !== right.type) { + return {type: 'null'} satisfies NullTypeNode + } + if (!isPrimitiveTypeNode(left) || !isPrimitiveTypeNode(right)) { + return {type: 'null'} satisfies NullTypeNode + } + return { + type: 'boolean', + value: evaluateComparison(node.op, left, right), + } satisfies BooleanTypeNode + } + case 'in': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } + if (right.type !== 'array') { + // Special case for global::path, since it can be used with in operator, but the type returned otherwise is a string + if (isFuncCall(node.right, 'global::path')) { + return {type: 'boolean'} + } return {type: 'null'} } - if (isPrimitiveTypeNode(left)) { - const resolved = resolveCondition(node, scope) + if (!isPrimitiveTypeNode(left) && left.type !== 'null') { return { type: 'boolean', - value: resolved, + value: false, } satisfies BooleanTypeNode } + return mapConcrete(right.of, scope, (arrayTypeNode) => { + if (left.type === 'null') { + return { + type: 'boolean', + value: arrayTypeNode.type === 'null', + } satisfies BooleanTypeNode + } + + if (left.value === undefined) { + return { + type: 'boolean', + } satisfies BooleanTypeNode + } + if (isPrimitiveTypeNode(arrayTypeNode)) { + if (arrayTypeNode.value === undefined) { + return { + type: 'boolean', + } satisfies BooleanTypeNode + } + + return { + type: 'boolean', + value: left.value === arrayTypeNode.value, + } satisfies BooleanTypeNode + } - return {type: 'null'} - } - case 'in': { - if (right.type === 'array') { - const resolved = resolveCondition(node, scope) return { type: 'boolean', - value: resolved, + value: false, } satisfies BooleanTypeNode - } - return {type: 'null'} + }) } case 'match': { - const resolved = resolveCondition(node, scope) - + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } return { type: 'boolean', - value: resolved, + value: match(left, right), } satisfies BooleanTypeNode } case '+': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'string' && right.type === 'string') { return { type: 'string', @@ -546,6 +639,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '-': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -558,6 +654,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '*': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -570,6 +669,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '/': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -582,6 +684,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '**': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -594,6 +699,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '%': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -606,6 +714,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } default: { + // TS only: make sure we handle all cases + node.op satisfies never + return { type: 'unknown', } satisfies UnknownTypeNode @@ -866,28 +977,38 @@ function handleParentNode({n}: ParentNode, scope: Scope): TypeNode { function handleNotNode(node: NotNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) - if (base.type === 'boolean' && base.value !== undefined) { - return {type: 'boolean', value: base.value === false} - } - return {type: 'boolean'} + return mapConcrete(base, scope, (base) => { + if (base.type === 'boolean') { + if (base.value !== undefined) { + return {type: 'boolean', value: base.value === false} + } + return {type: 'boolean'} + } + + return {type: 'null'} + }) } -function handleNegNode(node: NegNode, scope: Scope): NumberTypeNode | NullTypeNode { +function handleNegNode(node: NegNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) - if (base.type !== 'number') { - return {type: 'null'} - } - if (base.value !== undefined) { - return {type: 'number', value: -base.value} - } - return base + return mapConcrete(base, scope, (base) => { + if (base.type !== 'number') { + return {type: 'null'} + } + if (base.value !== undefined) { + return {type: 'number', value: -base.value} + } + return base + }) } -function handlePosNode(node: PosNode, scope: Scope): NumberTypeNode | NullTypeNode { +function handlePosNode(node: PosNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) - if (base.type !== 'number') { - return {type: 'null'} - } - return base + return mapConcrete(base, scope, (base) => { + if (base.type !== 'number') { + return {type: 'null'} + } + return base + }) } function handleEverythingNode(_: EverythingNode, scope: Scope): TypeNode { @@ -905,6 +1026,73 @@ function handleEverythingNode(_: EverythingNode, scope: Scope): TypeNode { } satisfies ArrayTypeNode> } +function handleAndNode(node: AndNode, scope: Scope): TypeNode { + const left = walk({node: node.left, scope}) + const right = walk({node: node.right, scope}) + return mapConcrete(left, scope, (lhs) => + mapConcrete(right, scope, (rhs) => { + if ( + (lhs.type === 'boolean' && lhs.value === false) || + (rhs.type === 'boolean' && rhs.value === false) + ) { + return {type: 'boolean', value: false} + } + + if (lhs.type !== 'boolean' || rhs.type !== 'boolean') { + if ( + (lhs.type === 'boolean' && lhs.value === undefined) || + (rhs.type === 'boolean' && rhs.value === undefined) + ) { + return nullUnion({type: 'boolean'}) + } + return {type: 'null'} + } + + if (lhs.value === true && rhs.value === true) { + return {type: 'boolean', value: true} + } + + return {type: 'boolean'} + }), + ) +} + +function handleOrNode(node: OrNode, scope: Scope): TypeNode { + const left = walk({node: node.left, scope}) + const right = walk({node: node.right, scope}) + return mapConcrete(left, scope, (lhs) => + mapConcrete(right, scope, (rhs) => { + // one of the sides is true the condition is true + if ( + (lhs.type === 'boolean' && lhs.value === true) || + (rhs.type === 'boolean' && rhs.value === true) + ) { + return {type: 'boolean', value: true} + } + + // if one of the sides is not a boolean, it's either a null or + // a null|boolean if the other side is an undefined boolean + if (lhs.type !== 'boolean' || rhs.type !== 'boolean') { + if ( + (lhs.type === 'boolean' && lhs.value === undefined) || + (rhs.type === 'boolean' && rhs.value === undefined) + ) { + return nullUnion({type: 'boolean'}) + } + + return {type: 'null'} + } + + // both sides are false, the condition is false + if (lhs.value === false && rhs.value === false) { + return {type: 'boolean', value: false} + } + + return {type: 'boolean'} + }), + ) +} + const OVERRIDE_TYPE_SYMBOL = Symbol('groq-js.type') /** @@ -960,12 +1148,12 @@ export function walk({node, scope}: {node: ExprNode; scope: Scope}): TypeNode { return handleOpCallNode(node, scope) } - case 'And': + case 'And': { + return handleAndNode(node, scope) + } + case 'Or': { - return { - type: 'boolean', - value: resolveCondition(node, scope), - } satisfies BooleanTypeNode + return handleOrNode(node, scope) } case 'Select': { @@ -1047,316 +1235,69 @@ function isPrimitiveTypeNode(node: TypeNode): node is PrimitiveTypeNode { return node.type === 'string' || node.type === 'number' || node.type === 'boolean' } -function evaluateEquality(left: TypeNode, right: TypeNode): boolean | undefined { - $trace('evaluateEquality %O', {left, right}) - if (left.type === 'null' && right.type === 'null') { - return true +function evaluateComparison( + opcall: OpCall, + left: PrimitiveTypeNode, + right: PrimitiveTypeNode, +): boolean | undefined { + if (left.value === undefined || right.value === undefined) { + return undefined } - - if ( - isPrimitiveTypeNode(left) && - isPrimitiveTypeNode(right) && - left.value !== undefined && - right.value !== undefined - ) { - return left.value === right.value - } - if (left.type === 'union' && isPrimitiveTypeNode(right)) { - for (const node of left.of) { - // both are primitive types, and their values are equal, we can return true - if (isPrimitiveTypeNode(node) && node.value === right.value) { - return true - } - - // both are the same type, but the value is undefined, we can't determine the result - if (isPrimitiveTypeNode(node) && node.value === undefined) { - return undefined - } + switch (opcall) { + case '==': { + return left.value === right.value } - } - if (left.type !== right.type) { - return false - } - return undefined -} - -/** - * Resolves the condition expression and returns a boolean value or undefined. - * Undefined is returned when the condition can't be resolved. - * - * @param expr - The expression node to resolve. - * @param scope - The scope in which the expression is evaluated. - * @returns The resolved boolean value or undefined. - */ - -// eslint-disable-next-line complexity, max-statements -function resolveCondition(expr: ExprNode, scope: Scope): boolean | undefined { - $trace('resolveCondition.expr %O', expr) - - switch (expr.type) { - case 'AccessAttribute': - case 'AccessElement': - case 'Value': { - const value = mapConcrete(walk({node: expr, scope}), scope, (node) => node) - if (value.type === 'boolean') { - return value.value - } - - if (value.type === 'null' || value.type === 'object' || value.type === 'array') { - return false - } - - return undefined + case '<': { + return left.value < right.value } - case 'And': { - const left = resolveCondition(expr.left, scope) - $trace('resolveCondition.and.left %O', left) - if (left === false) { - return false - } - - const right = resolveCondition(expr.right, scope) - $trace('resolveCondition.and.right %O', right) - if (right === false) { - return false - } - - if (left === undefined || right === undefined) { - return undefined - } - - return true + case '<=': { + return left.value <= right.value } - case 'Or': { - $trace('resolveCondition.or.expr %O', expr) - const left = resolveCondition(expr.left, scope) - $trace('resolveCondition.or.left %O', left) - if (left === true) { - return true - } - - const right = resolveCondition(expr.right, scope) - $trace('resolveCondition.or.right %O', right) - if (right === true) { - return true - } - if (left === undefined || right === undefined) { - return undefined - } - - return false + case '>': { + return left.value > right.value } - case 'OpCall': { - const left = walk({node: expr.left, scope}) - const right = walk({node: expr.right, scope}) - $trace('opcall "%s" %O', expr.op, {left, right}) - - if (left.type === 'unknown' || right.type === 'unknown') { - return undefined - } - - switch (expr.op) { - case '==': { - return evaluateEquality(left, right) - } - case '!=': { - const result = evaluateEquality(left, right) - if (result === undefined) { - return undefined - } - return !result - } - case 'in': { - if (right.type === 'array') { - if (left.type === 'null' && right.of.type === 'unknown') { - return undefined - } - if (left.type === 'null' && right.of.type === 'null') { - return true - } - if (isPrimitiveTypeNode(left)) { - // eslint-disable-next-line max-depth - if (right.of.type === 'unknown') { - return undefined - } - // eslint-disable-next-line max-depth - if (left.value === undefined) { - return undefined - } - - // eslint-disable-next-line max-depth - if (isPrimitiveTypeNode(right.of)) { - // eslint-disable-next-line max-depth - if (right.of.value === undefined) { - return undefined - } - return left.value === right.of.value - } - // eslint-disable-next-line max-depth - if (right.of.type === 'union') { - // eslint-disable-next-line max-depth - for (const node of right.of.of) { - // eslint-disable-next-line max-depth - if (node.type === 'unknown') { - return undefined - } - // eslint-disable-next-line max-depth - if (isPrimitiveTypeNode(node) && left.value === node.value) { - return true - } - // eslint-disable-next-line max-depth - if (left.type === node.type && node.value === undefined) { - return undefined - } - } - } - } - } - - return false - } - case 'match': { - let tokens: Token[] = [] - let patterns: Pattern[] = [] - if (left.type === 'string') { - if (left.value === undefined) { - return undefined - } - tokens = tokens.concat(matchTokenize(left.value)) - } - if (left.type === 'array') { - if (left.of.type === 'unknown') { - return undefined - } - if (left.of.type === 'string') { - // eslint-disable-next-line max-depth - if (left.of.value === undefined) { - return undefined - } - - tokens = tokens.concat(matchTokenize(left.of.value)) - } - if (left.of.type === 'union') { - // eslint-disable-next-line max-depth - for (const node of left.of.of) { - // eslint-disable-next-line max-depth - if (node.type === 'string' && node.value !== undefined) { - tokens = tokens.concat(matchTokenize(node.value)) - } - } - } - } - - if (right.type === 'string') { - if (right.value === undefined) { - return undefined - } - patterns = patterns.concat(matchAnalyzePattern(right.value)) - } - if (right.type === 'array') { - if (right.of.type === 'unknown') { - return undefined - } - if (right.of.type === 'string') { - // eslint-disable-next-line max-depth - if (right.of.value === undefined) { - return undefined - } - patterns = patterns.concat(matchAnalyzePattern(right.of.value)) - } - if (right.of.type === 'union') { - // eslint-disable-next-line max-depth - for (const node of right.of.of) { - // eslint-disable-next-line max-depth - if (node.type === 'string') { - // eslint-disable-next-line max-depth - if (node.value === undefined) { - return undefined - } - patterns = patterns.concat(matchAnalyzePattern(node.value)) - } - - // eslint-disable-next-line max-depth - if (node.type !== 'string') { - return false - } - } - } - } - return matchText(tokens, patterns) - } - case '<': { - if (isPrimitiveTypeNode(left) && isPrimitiveTypeNode(right)) { - if (left.value === undefined || right.value === undefined) { - return undefined - } - return left.value < right.value - } - - return undefined - } - case '<=': { - if (isPrimitiveTypeNode(left) && isPrimitiveTypeNode(right)) { - if (left.value === undefined || right.value === undefined) { - return undefined - } - return left.value <= right.value - } - - return undefined - } - case '>': { - if (isPrimitiveTypeNode(left) && isPrimitiveTypeNode(right)) { - if (left.value === undefined || right.value === undefined) { - return undefined - } - return left.value > right.value - } - - return undefined - } - case '>=': { - if (isPrimitiveTypeNode(left) && isPrimitiveTypeNode(right)) { - if (left.value === undefined || right.value === undefined) { - return undefined - } - return left.value >= right.value - } - - return undefined - } - - default: { - return undefined - } - } + case '>=': { + return left.value >= right.value } - - case 'Not': { - const result = resolveCondition(expr.base, scope) - // check if the result is undefined or false. Undefined means that the condition can't be resolved, and we should keep the node - return result === undefined ? undefined : result === false + default: { + throw new Error(`unknown comparison operator ${opcall}`) } + } +} - case 'Group': { - return resolveCondition(expr.base, scope) - } +function booleanValue(node: TypeNode): boolean | undefined { + // if the node is unknown, we can't match it so we return undefined + if (node.type === 'unknown') { + return undefined + } - default: { - return undefined + // if the node is a boolean, we can match it, reuse the value + if (node.type === 'boolean') { + return node.value + } + + if (node.type === 'union') { + for (const sub of node.of) { + const match = booleanValue(sub) + if (match !== false) { + return match + } } } + + return false } // eslint-disable-next-line complexity, max-statements function resolveFilter(expr: ExprNode, scope: Scope): UnionTypeNode { $trace('resolveFilter.expr %O', expr) - const filtered = scope.value.of.filter( - (node) => - // create a new scope with the current scopes parent as the parent. It's only a temporary scope since we only want to resolve the condition - // check if the result is true or undefined. Undefined means that the condition can't be resolved, and we should keep the node - resolveCondition(expr, scope.createHidden([node])) !== false, - ) + const filtered = scope.value.of.filter((node) => { + // create a new scope with the current scopes parent as the parent. It's only a temporary scope since we only want to resolve the condition + // and check if the result is "matchable" + const cond = walk({node: expr, scope: scope.createHidden([node])}) + const isMatch = booleanValue(cond) + return isMatch === undefined || isMatch === true + }) $trace( `resolveFilter ${expr.type === 'OpCall' ? `${expr.type}/${expr.op}` : expr.type} %O`, filtered, diff --git a/src/typeEvaluator/typeHelpers.ts b/src/typeEvaluator/typeHelpers.ts index bceb8c6a..19953acb 100644 --- a/src/typeEvaluator/typeHelpers.ts +++ b/src/typeEvaluator/typeHelpers.ts @@ -1,3 +1,4 @@ +import type {ExprNode} from '../nodeTypes' import {optimizeUnions} from './optimizations' import type {Scope} from './scope' import type { @@ -11,6 +12,7 @@ import type { StringTypeNode, TypeNode, UnionTypeNode, + UnknownTypeNode, } from './types' /** @@ -99,7 +101,7 @@ export function resolveInline(node: TypeNode, scope: Scope): Exclude TypeNode, mergeUnions: (nodes: TypeNode[]) => TypeNode = (nodes) => optimizeUnions({type: 'union', of: nodes}), +): TypeNode { + return mapUnion( + node, + scope, + (node) => { + if (node.type === 'unknown') { + return node + } + return mapper(node) + }, + mergeUnions, + ) +} + +/** + * mapUnion extracts a _concrete type_ OR an unknown type from a type node, applies the mapping + * function to it and returns. Most notably, this will work through unions + * (applying the mapping function for each variant) and inline (resolving the + * reference). + * This method should _only_ be used if you need to handle unknown types, ie when resolving two sides of an and node, and we don't want to abort if one side is unknown. + * In most cases, you should use `mapConcrete` instead. + **/ +export function mapUnion( + node: TypeNode, + scope: Scope, + mapper: (node: ConcreteTypeNode | UnknownTypeNode) => T, + mergeUnions: (nodes: TypeNode[]) => TypeNode = (nodes) => + optimizeUnions({type: 'union', of: nodes}), ): TypeNode { switch (node.type) { case 'boolean': @@ -118,17 +148,24 @@ export function mapConcrete( case 'object': case 'string': case 'number': - return mapper(node) case 'unknown': - return node + return mapper(node) case 'union': - return mergeUnions(node.of.map((inner) => mapConcrete(inner, scope, mapper), mergeUnions)) + return mergeUnions(node.of.map((inner) => mapUnion(inner, scope, mapper), mergeUnions)) case 'inline': { const resolvedInline = resolveInline(node, scope) - return mapConcrete(resolvedInline, scope, mapper, mergeUnions) + return mapUnion(resolvedInline, scope, mapper, mergeUnions) } default: // @ts-expect-error - all types should be handled throw new Error(`Unknown type: ${node.type}`) } } + +export function isFuncCall(node: ExprNode, name: string): boolean { + if (node.type === 'Group') { + return isFuncCall(node.base, name) + } + + return node.type === 'FuncCall' && `${node.namespace}::${node.name}` === name +} diff --git a/tap-snapshots/test/typeEvaluate.test.ts.test.cjs b/tap-snapshots/test/typeEvaluate.test.ts.test.cjs index 5422281d..af61a8f5 100644 --- a/tap-snapshots/test/typeEvaluate.test.ts.test.cjs +++ b/tap-snapshots/test/typeEvaluate.test.ts.test.cjs @@ -1131,11 +1131,10 @@ exports[`test/typeEvaluate.test.ts TAP misc > must match snapshot 1`] = ` Object { "of": Object { "attributes": Object { - "andWithAttriute": Object { + "andWithAttribute": Object { "type": "objectAttribute", "value": Object { "type": "boolean", - "value": true, }, }, "group": Object { @@ -1158,6 +1157,18 @@ Object { "type": "boolean", }, }, + "notMissingAttribute": Object { + "type": "objectAttribute", + "value": Object { + "type": "null", + }, + }, + "notNumber": Object { + "type": "objectAttribute", + "value": Object { + "type": "null", + }, + }, "pt": Object { "type": "objectAttribute", "value": Object { diff --git a/test/typeEvaluate.test.ts b/test/typeEvaluate.test.ts index 77738640..56e44b55 100644 --- a/test/typeEvaluate.test.ts +++ b/test/typeEvaluate.test.ts @@ -454,7 +454,7 @@ t.test('filtering on sub-child', (t) => { t.end() }) -t.test('in operator', (t) => { +t.test('in operator, constants', (t) => { const query = `*[_type in ["author", "post"]]` const ast = parse(query) const res = typeEvaluate(ast, schemas) @@ -469,6 +469,57 @@ t.test('in operator', (t) => { t.end() }) +t.test('in operator with optional field', (t) => { + const query = `*[_type == "post"] { + "authors": *[_type == "author" && _id in ^.allAuthorOrGhost[]._ref] + }` + const ast = parse(query) + const res = typeEvaluate(ast, schemas) + t.strictSame(res, { + type: 'array', + of: { + type: 'object', + attributes: { + authors: { + type: 'objectAttribute', + value: { + type: 'array', + of: findSchemaType('author'), + }, + }, + }, + }, + }) + + t.end() +}) + +t.test('sanity-io/sanity:6628: in operator with optional field', (t) => { + const query = `*[_type == "test" && foo in ["bar", "baz"]]{foo}` + const ast = parse(query) + const res = typeEvaluate(ast, [ + { + type: 'document', + name: 'test', + attributes: { + _type: {type: 'objectAttribute', value: {type: 'string', value: 'test'}}, + foo: {type: 'objectAttribute', value: {type: 'string'}, optional: true}, + }, + }, + ]) + t.strictSame(res, { + type: 'array', + of: { + type: 'object', + attributes: { + foo: {type: 'objectAttribute', value: nullUnion({type: 'string'})}, + }, + }, + }) + + t.end() +}) + t.test('attribute access on inline types', (t) => { const query = `*[_type == "ghost"] { "conceptNames": concepts[].name @@ -684,8 +735,9 @@ t.test('values in projection', (t) => { "exp": 3 ** 3, "mod": 3 % 2, "arr": [1, 2, 3] + [4, 5, 6], - "and": 3 > foo && 3 > bar, - "or": 3 > foo || 3 > bar, + "andNotExists": 3 > foo && 3 > bar, + "and": 3 > age && age < 5, + "or": 3 > age || 3 > bar, }` const ast = parse(query) const res = typeEvaluate(ast, schemas) @@ -834,19 +886,23 @@ t.test('values in projection', (t) => { }, }, }, + andNotExists: { + type: 'objectAttribute', + value: { + type: 'null', + }, + }, and: { type: 'objectAttribute', value: { type: 'boolean', - value: undefined, }, }, or: { type: 'objectAttribute', - value: { + value: nullUnion({ type: 'boolean', - value: undefined, - }, + }), }, }, }, @@ -1313,7 +1369,7 @@ t.test('with select, not guaranteed & with fallback', (t) => { const query = `*[_type == "author" || _type == "post"] { _type, "something": select( - _id > 5 => _id, + _id == "5" => _id, "old id" ) }` @@ -1672,14 +1728,24 @@ t.test('misc', (t) => { const query = `*[]{ "group": ((3 + 4) * 5), "notBool": !false, - "notField": !someAttriute, + "notField": !someAttribute, + "notNumber": !34, + "notMissingAttribute": !missingAttribute, "unknownParent": ^._id, "unknownParent2": ^.^.^.^.^.^.^.^._id, - "andWithAttriute": !false && !someAttriute, + "andWithAttribute": !false && !someAttribute, "pt": pt::text(block) }` const ast = parse(query) - const res = typeEvaluate(ast, [{type: 'document', name: 'foo', attributes: {}}]) + const res = typeEvaluate(ast, [ + { + type: 'document', + name: 'foo', + attributes: { + someAttribute: {type: 'objectAttribute', value: {type: 'boolean'}}, + }, + }, + ]) t.matchSnapshot(res) t.end() diff --git a/test/typeEvaluateCompare.test.ts b/test/typeEvaluateCompare.test.ts index 8bc95459..6cfef172 100644 --- a/test/typeEvaluateCompare.test.ts +++ b/test/typeEvaluateCompare.test.ts @@ -78,32 +78,32 @@ const primitives: AnnotatedValue[] = [ key: '1', value: 1, types: [ - {desc: '1', type: {type: 'number', value: 1}}, - {desc: 'number', type: {type: 'number'}}, + {desc: 'number(1)', type: {type: 'number', value: 1}}, + {desc: 'number(undefined)', type: {type: 'number'}}, ], }, { key: 'hello', value: 'hello', types: [ - {desc: 'hello', type: {type: 'string', value: 'hello'}}, - {desc: 'string', type: {type: 'string'}}, + {desc: 'string(hello)', type: {type: 'string', value: 'hello'}}, + {desc: 'string(undefined)', type: {type: 'string'}}, ], }, { key: 'true', value: true, types: [ - {desc: 'true', type: {type: 'boolean', value: true}}, - {desc: 'boolean', type: {type: 'boolean'}}, + {desc: 'boolean(true)', type: {type: 'boolean', value: true}}, + {desc: 'boolean(undefined)', type: {type: 'boolean'}}, ], }, { key: 'false', value: false, types: [ - {desc: 'false', type: {type: 'boolean', value: false}}, - {desc: 'boolean', type: {type: 'boolean'}}, + {desc: 'boolean(false)', type: {type: 'boolean', value: false}}, + {desc: 'boolean(undefined)', type: {type: 'boolean'}}, ], }, ] @@ -207,6 +207,8 @@ const ALL_CATEGORIES = [ Category.ARRAYS_IN_ARRAYS, ] +const trivialVariant = [Category.PRIMITIVES, Category.OBJECT, Category.ARRAY] + type CachedResult = { params: ExprNode[] node: ExprNode @@ -379,13 +381,29 @@ t.test('Slice', async (t) => { }) }) +t.test(`And`, async (t) => { + subtestBinary({ + t, + variants1: trivialVariant, + variants2: trivialVariant, + build: (left, right) => ({type: 'And', left, right}), + }) +}) + +t.test(`Or`, async (t) => { + subtestBinary({ + t, + variants1: trivialVariant, + variants2: trivialVariant, + build: (left, right) => ({type: 'Or', left, right}), + }) +}) + // It's too much to test _every_ possible combination of binary operations. For // each operator we therefore keep track of which variants are interesting to // test for. We already know that many operations don't care about deeply nested // objects/arrays so we avoid testing for those. -const trivialVariant = [Category.PRIMITIVES, Category.OBJECT, Category.ARRAY] - const opVariants: Record = { // + is very polymorphic so we want to test it for everything. '+': ALL_CATEGORIES, From b09a7ca58d1ab86818f14f58ff8c6a420b6ce935 Mon Sep 17 00:00:00 2001 From: Sindre Gulseth Date: Wed, 21 Aug 2024 00:41:42 +0200 Subject: [PATCH 2/4] refactor(typeEvaluator): use boolean interpretation to resolve conditions feat(typeEvaluator): separate booleans logic into functions --- src/typeEvaluator/booleans.ts | 131 ++++++++++++++++++++++++++++++ src/typeEvaluator/typeEvaluate.ts | 89 +++----------------- test/typeEvaluate.test.ts | 1 + 3 files changed, 144 insertions(+), 77 deletions(-) create mode 100644 src/typeEvaluator/booleans.ts diff --git a/src/typeEvaluator/booleans.ts b/src/typeEvaluator/booleans.ts new file mode 100644 index 00000000..98646722 --- /dev/null +++ b/src/typeEvaluator/booleans.ts @@ -0,0 +1,131 @@ +import type {Scope} from './scope' +import {nullUnion, resolveInline} from './typeHelpers' +import type {TypeNode} from './types' + +type BooleanInterpretation = { + canBeTrue: boolean + canBeFalse: boolean + canBeNull: boolean +} + +/** + * booleanValue takes a TypeNode and returns a BooleanInterpretation. + * BooleanInterpretation is a matrix of three booleans: + * - canBeTrue: whether the TypeNode can resolve to true + * - canBeFalse: whether the TypeNode can resolve to false + * - canBeNull: whether the TypeNode can resolve to null + * This is a helper method intended to determine the possible values of a boolean expression. + * When resolving a boolean expression, we might not be able to determine the exact value of the expression, + * but we can determine the possible values of the expression, Multiple values can be true at the same time. + * + * @param node - The TypeNode to evaluate + * @returns BooleanInterpretation + * @internal + */ +export function booleanValue(node: TypeNode, scope: Scope): BooleanInterpretation { + switch (node.type) { + case 'unknown': { + return {canBeTrue: true, canBeFalse: true, canBeNull: true} + } + case 'boolean': { + if (node.value === true) { + return {canBeTrue: true, canBeFalse: false, canBeNull: false} + } + if (node.value === false) { + return {canBeTrue: false, canBeFalse: true, canBeNull: false} + } + + return {canBeTrue: true, canBeFalse: true, canBeNull: false} + } + case 'union': { + const value = {canBeTrue: false, canBeFalse: false, canBeNull: false} + for (const sub of node.of) { + const match = booleanValue(sub, scope) + if (match.canBeNull) { + value.canBeNull = true + } + if (match.canBeTrue) { + value.canBeTrue = true + } + if (match.canBeFalse) { + value.canBeFalse = true + } + } + return value + } + case 'inline': { + const resolved = resolveInline(node, scope) + return booleanValue(resolved, scope) + } + case 'null': + case 'string': + case 'number': + case 'object': + case 'array': { + return {canBeTrue: false, canBeFalse: false, canBeNull: true} + } + default: { + // @ts-expect-error - we should have handled all cases + throw new Error(`unknown node type ${node.type}`) + } + } +} + +export function booleanOr( + left: BooleanInterpretation, + right: BooleanInterpretation, +): BooleanInterpretation { + // If either side can only be true, the expression can only be true, so we short-circuit + if (left.canBeTrue && !left.canBeFalse && !left.canBeNull) return left + if (right.canBeTrue && !right.canBeFalse && !right.canBeNull) return right + + return { + // Either side can be true for the expression to be true + canBeTrue: left.canBeTrue || right.canBeTrue, + // Both sides must be false for the expression to be false + canBeFalse: left.canBeFalse && right.canBeFalse, + // if either side can be null, the expression can be null if the other side can't only be true + canBeNull: left.canBeNull || right.canBeNull, + } +} + +export function booleanAnd( + left: BooleanInterpretation, + right: BooleanInterpretation, +): BooleanInterpretation { + // If either side can only be fales, the expression can only be false, so we short-circuit + if (left.canBeFalse && !left.canBeTrue && !left.canBeNull) return left + if (right.canBeFalse && !right.canBeTrue && !right.canBeNull) return right + + return { + // Both sides must be true for the expression to be true + canBeTrue: left.canBeTrue && right.canBeTrue, + // if either side can be false, the expression can be false + canBeFalse: left.canBeFalse || right.canBeFalse, + // if either side can be null, the expression can be null + canBeNull: left.canBeNull || right.canBeNull, + } +} + +export function booleanInterpretationToTypeNode(bool: BooleanInterpretation): TypeNode { + if (bool.canBeTrue) { + if (bool.canBeFalse) { + if (bool.canBeNull) { + return nullUnion({type: 'boolean'}) + } + return {type: 'boolean'} + } + if (bool.canBeNull) { + return nullUnion({type: 'boolean', value: true}) + } + return {type: 'boolean', value: true} + } + + if (bool.canBeFalse) { + if (bool.canBeNull) { + return nullUnion({type: 'boolean', value: false}) + } + return {type: 'boolean', value: false} + } + return {type: 'null'} +} diff --git a/src/typeEvaluator/typeEvaluate.ts b/src/typeEvaluator/typeEvaluate.ts index e1a3f1ce..2b73ee7e 100644 --- a/src/typeEvaluator/typeEvaluate.ts +++ b/src/typeEvaluator/typeEvaluate.ts @@ -27,6 +27,7 @@ import type { SliceNode, ValueNode, } from '../nodeTypes' +import {booleanAnd, booleanInterpretationToTypeNode, booleanOr, booleanValue} from './booleans' import {handleFuncCallNode} from './functions' import {match} from './matching' import {optimizeUnions} from './optimizations' @@ -220,17 +221,17 @@ function handleObjectNode(node: ObjectNode, scope: Scope): TypeNode { } if (attr.type === 'ObjectConditionalSplat') { - const condition = booleanValue(walk({node: attr.condition, scope})) + const condition = booleanValue(walk({node: attr.condition, scope}), scope) $trace('object.conditional.splat.condition %O', condition) // condition is never met, skip this attribute - if (condition === false) { + if (condition.canBeTrue === false) { continue } const attributeNode = handleObjectSplatNode(attr, scope) $trace('object.conditional.splat.result %O', attributeNode) // condition is always met, we can treat this as a normal splat - if (condition === true) { + if (condition.canBeFalse === false && condition.canBeNull === false) { switch (attributeNode.type) { case 'object': { splatVariants.push([idx, attributeNode]) @@ -1031,28 +1032,9 @@ function handleAndNode(node: AndNode, scope: Scope): TypeNode { const right = walk({node: node.right, scope}) return mapConcrete(left, scope, (lhs) => mapConcrete(right, scope, (rhs) => { - if ( - (lhs.type === 'boolean' && lhs.value === false) || - (rhs.type === 'boolean' && rhs.value === false) - ) { - return {type: 'boolean', value: false} - } - - if (lhs.type !== 'boolean' || rhs.type !== 'boolean') { - if ( - (lhs.type === 'boolean' && lhs.value === undefined) || - (rhs.type === 'boolean' && rhs.value === undefined) - ) { - return nullUnion({type: 'boolean'}) - } - return {type: 'null'} - } - - if (lhs.value === true && rhs.value === true) { - return {type: 'boolean', value: true} - } + const value = booleanAnd(booleanValue(lhs, scope), booleanValue(rhs, scope)) - return {type: 'boolean'} + return booleanInterpretationToTypeNode(value) }), ) } @@ -1062,33 +1044,9 @@ function handleOrNode(node: OrNode, scope: Scope): TypeNode { const right = walk({node: node.right, scope}) return mapConcrete(left, scope, (lhs) => mapConcrete(right, scope, (rhs) => { - // one of the sides is true the condition is true - if ( - (lhs.type === 'boolean' && lhs.value === true) || - (rhs.type === 'boolean' && rhs.value === true) - ) { - return {type: 'boolean', value: true} - } - - // if one of the sides is not a boolean, it's either a null or - // a null|boolean if the other side is an undefined boolean - if (lhs.type !== 'boolean' || rhs.type !== 'boolean') { - if ( - (lhs.type === 'boolean' && lhs.value === undefined) || - (rhs.type === 'boolean' && rhs.value === undefined) - ) { - return nullUnion({type: 'boolean'}) - } - - return {type: 'null'} - } + const value = booleanOr(booleanValue(lhs, scope), booleanValue(rhs, scope)) - // both sides are false, the condition is false - if (lhs.value === false && rhs.value === false) { - return {type: 'boolean', value: false} - } - - return {type: 'boolean'} + return booleanInterpretationToTypeNode(value) }), ) } @@ -1265,38 +1223,15 @@ function evaluateComparison( } } -function booleanValue(node: TypeNode): boolean | undefined { - // if the node is unknown, we can't match it so we return undefined - if (node.type === 'unknown') { - return undefined - } - - // if the node is a boolean, we can match it, reuse the value - if (node.type === 'boolean') { - return node.value - } - - if (node.type === 'union') { - for (const sub of node.of) { - const match = booleanValue(sub) - if (match !== false) { - return match - } - } - } - - return false -} - // eslint-disable-next-line complexity, max-statements function resolveFilter(expr: ExprNode, scope: Scope): UnionTypeNode { $trace('resolveFilter.expr %O', expr) const filtered = scope.value.of.filter((node) => { // create a new scope with the current scopes parent as the parent. It's only a temporary scope since we only want to resolve the condition - // and check if the result is "matchable" - const cond = walk({node: expr, scope: scope.createHidden([node])}) - const isMatch = booleanValue(cond) - return isMatch === undefined || isMatch === true + // and check if the result can be true. + const subScope = scope.createHidden([node]) + const cond = walk({node: expr, scope: subScope}) + return booleanValue(cond, subScope).canBeTrue }) $trace( `resolveFilter ${expr.type === 'OpCall' ? `${expr.type}/${expr.op}` : expr.type} %O`, diff --git a/test/typeEvaluate.test.ts b/test/typeEvaluate.test.ts index 56e44b55..a3f9dd1a 100644 --- a/test/typeEvaluate.test.ts +++ b/test/typeEvaluate.test.ts @@ -902,6 +902,7 @@ t.test('values in projection', (t) => { type: 'objectAttribute', value: nullUnion({ type: 'boolean', + value: true, }), }, }, From dc23a9825fea7421e596c89584453a9868dfd0cb Mon Sep 17 00:00:00 2001 From: Sindre Gulseth Date: Thu, 22 Aug 2024 15:51:09 +0200 Subject: [PATCH 3/4] feat(typeEvaluator): do not let unknown always resolve to unknown --- src/typeEvaluator/functions.ts | 201 ++++++++++++++++++++---------- src/typeEvaluator/typeEvaluate.ts | 65 +++++++--- src/typeEvaluator/typeHelpers.ts | 39 +----- 3 files changed, 189 insertions(+), 116 deletions(-) diff --git a/src/typeEvaluator/functions.ts b/src/typeEvaluator/functions.ts index e386bfbb..eba66d01 100644 --- a/src/typeEvaluator/functions.ts +++ b/src/typeEvaluator/functions.ts @@ -15,12 +15,16 @@ function unionWithoutNull(unionTypeNode: TypeNode): TypeNode { return unionTypeNode } +// eslint-disable-next-line complexity export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { switch (`${node.namespace}.${node.name}`) { case 'array.compact': { const arg = walk({node: node.args[0], scope}) return mapConcrete(arg, scope, (arg) => { + if (arg.type === 'unknown') { + return nullUnion({type: 'array', of: {type: 'unknown'}}) + } if (arg.type !== 'array') { return {type: 'null'} } @@ -39,17 +43,20 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { return mapConcrete(arrayArg, scope, (arrayArg) => mapConcrete(sepArg, scope, (sepArg) => { - if (arrayArg.type !== 'array') { - return {type: 'null'} + if (arrayArg.type === 'unknown' || sepArg.type === 'unknown') { + return nullUnion({type: 'string'}) } - if (sepArg.type !== 'string') { + if (arrayArg.type !== 'array' || sepArg.type !== 'string') { return {type: 'null'} } return mapConcrete(arrayArg.of, scope, (of) => { + if (of.type === 'unknown') { + return nullUnion({type: 'string'}) + } // we can only join strings, numbers, and booleans if (of.type !== 'string' && of.type !== 'number' && of.type !== 'boolean') { - return {type: 'unknown'} + return {type: 'null'} } return {type: 'string'} @@ -62,6 +69,9 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const arg = walk({node: node.args[0], scope}) return mapConcrete(arg, scope, (arg) => { + if (arg.type === 'unknown') { + return nullUnion({type: 'array', of: {type: 'unknown'}}) + } if (arg.type !== 'array') { return {type: 'null'} } @@ -74,34 +84,39 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const arg = walk({node: node.args[0], scope}) return mapConcrete(arg, scope, (arg) => { - if (arg.type === 'string') { - if (arg.value !== undefined) { - return { - type: 'string', - value: arg.value.toLowerCase(), - } - } - return {type: 'string'} + if (arg.type === 'unknown') { + return nullUnion({type: 'string'}) } - return {type: 'null'} + if (arg.type !== 'string') { + return {type: 'null'} + } + if (arg.value !== undefined) { + return { + type: 'string', + value: arg.value.toLowerCase(), + } + } + return {type: 'string'} }) } case 'global.upper': { const arg = walk({node: node.args[0], scope}) return mapConcrete(arg, scope, (arg) => { - if (arg.type === 'string') { - if (arg.value !== undefined) { - return { - type: 'string', - value: arg.value.toUpperCase(), - } + if (arg.type === 'unknown') { + return nullUnion({type: 'string'}) + } + if (arg.type !== 'string') { + return {type: 'null'} + } + if (arg.value !== undefined) { + return { + type: 'string', + value: arg.value.toUpperCase(), } - return {type: 'string'} } - - return {type: 'null'} + return {type: 'string'} }) } case 'dateTime.now': { @@ -116,6 +131,10 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.path': { const arg = walk({node: node.args[0], scope}) return mapConcrete(arg, scope, (arg) => { + if (arg.type === 'unknown') { + return nullUnion({type: 'string'}) + } + if (arg.type === 'string') { return {type: 'string'} } @@ -150,6 +169,10 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const arg = walk({node: node.args[0], scope}) return mapConcrete(arg, scope, (arg) => { + if (arg.type === 'unknown') { + return nullUnion({type: 'string'}) + } + if (arg.type === 'array') { return {type: 'number'} } @@ -162,6 +185,10 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const arg = walk({node: node.args[0], scope}) return mapConcrete(arg, scope, (arg) => { + if (arg.type === 'unknown') { + return nullUnion({type: 'string'}) + } + if (arg.type === 'string') { return nullUnion({type: 'string'}) // we don't know wether the string is a valid date or not, so we return a [null, string]-union } @@ -174,11 +201,10 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const arg = walk({node: node.args[0], scope}) return mapConcrete(arg, scope, (arg) => { - if (arg.type === 'array') { - return {type: 'number'} + if (arg.type === 'unknown') { + return nullUnion({type: 'number'}) } - - if (arg.type === 'string') { + if (arg.type === 'array' || arg.type === 'string') { return {type: 'number'} } @@ -194,12 +220,20 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const numNode = walk({node: node.args[0], scope}) return mapConcrete(numNode, scope, (num) => { + if (num.type === 'unknown') { + return nullUnion({type: 'number'}) + } + if (num.type !== 'number') { return {type: 'null'} } if (node.args.length === 2) { const precisionNode = walk({node: node.args[1], scope}) return mapConcrete(precisionNode, scope, (precision) => { + if (precision.type === 'unknown') { + return nullUnion({type: 'number'}) + } + if (precision.type !== 'number') { return {type: 'null'} } @@ -215,6 +249,10 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.string': { const arg = walk({node: node.args[0], scope}) return mapConcrete(arg, scope, (node) => { + if (node.type === 'unknown') { + return nullUnion({type: 'string'}) + } + if (node.type === 'string' || node.type === 'number' || node.type === 'boolean') { if (node.value) { return { @@ -236,19 +274,27 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const values = walk({node: node.args[0], scope}) // use mapConcrete to get concrete resolved value, it will also handle cases where the value is a union return mapConcrete(values, scope, (node) => { + if (node.type === 'unknown') { + return nullUnion({type: 'number'}) + } + // Aggregate functions can only be applied to arrays - if (node.type === 'array') { - // Resolve the concrete type of the array elements - return mapConcrete(node.of, scope, (node) => { - // Math functions can only be applied to numbers, but we should also ignore nulls - if (node.type === 'number' || node.type === 'null') { - return {type: 'number'} - } - return {type: 'null'} - }) + if (node.type !== 'array') { + return {type: 'null'} } - return {type: 'null'} + // Resolve the concrete type of the array elements + return mapConcrete(node.of, scope, (node) => { + if (node.type === 'unknown') { + return nullUnion({type: 'number'}) + } + + // Math functions can only be applied to numbers, but we should also ignore nulls + if (node.type === 'number' || node.type === 'null') { + return {type: 'number'} + } + return {type: 'null'} + }) }) } @@ -256,19 +302,26 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const values = walk({node: node.args[0], scope}) // use mapConcrete to get concrete resolved value, it will also handle cases where the value is a union return mapConcrete(values, scope, (node) => { + if (node.type === 'unknown') { + return nullUnion({type: 'number'}) + } + // Aggregate functions can only be applied to arrays - if (node.type === 'array') { - // Resolve the concrete type of the array elements - return mapConcrete(node.of, scope, (node) => { - // Math functions can only be applied to numbers - if (node.type === 'number') { - return {type: 'number'} - } - return {type: 'null'} - }) + if (node.type !== 'array') { + return {type: 'null'} } + // Resolve the concrete type of the array elements + return mapConcrete(node.of, scope, (node) => { + if (node.type === 'unknown') { + return nullUnion({type: 'number'}) + } - return {type: 'null'} + // Math functions can only be applied to numbers + if (node.type === 'number') { + return {type: 'number'} + } + return {type: 'null'} + }) }) } @@ -277,19 +330,27 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const values = walk({node: node.args[0], scope}) // use mapConcrete to get concrete resolved value, it will also handle cases where the value is a union return mapConcrete(values, scope, (node) => { + if (node.type === 'unknown') { + return nullUnion({type: 'number'}) + } + // Aggregate functions can only be applied to arrays - if (node.type === 'array') { - // Resolve the concrete type of the array elements - return mapConcrete(node.of, scope, (node) => { - // Math functions can only be applied to numbers - if (node.type === 'number') { - return node - } - return {type: 'null'} - }) + if (node.type !== 'array') { + return {type: 'null'} } - return {type: 'null'} + // Resolve the concrete type of the array elements + return mapConcrete(node.of, scope, (node) => { + if (node.type === 'unknown') { + return nullUnion({type: 'number'}) + } + + // Math functions can only be applied to numbers + if (node.type === 'number') { + return node + } + return {type: 'null'} + }) }) } @@ -301,16 +362,17 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { type: 'string', } } + case 'string.startsWith': { const strTypeNode = walk({node: node.args[0], scope}) const prefixTypeNode = walk({node: node.args[1], scope}) return mapConcrete(strTypeNode, scope, (strNode) => { - if (strNode.type !== 'string') { - return {type: 'null'} - } - return mapConcrete(prefixTypeNode, scope, (prefixNode) => { - if (prefixNode.type !== 'string') { + if (strNode.type === 'unknown' || prefixNode.type === 'unknown') { + return nullUnion({type: 'boolean'}) + } + + if (strNode.type !== 'string' || prefixNode.type !== 'string') { return {type: 'null'} } @@ -322,12 +384,12 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const strTypeNode = walk({node: node.args[0], scope}) const sepTypeNode = walk({node: node.args[1], scope}) return mapConcrete(strTypeNode, scope, (strNode) => { - if (strNode.type !== 'string') { - return {type: 'null'} - } - return mapConcrete(sepTypeNode, scope, (sepNode) => { - if (sepNode.type !== 'string') { + if (strNode.type === 'unknown' || sepNode.type === 'unknown') { + return nullUnion({type: 'array', of: {type: 'string'}}) + } + + if (strNode.type !== 'string' || sepNode.type !== 'string') { return {type: 'null'} } @@ -338,6 +400,9 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'sanity.versionOf': { const typeNode = walk({node: node.args[0], scope}) return mapConcrete(typeNode, scope, (typeNode) => { + if (typeNode.type === 'unknown') { + return nullUnion({type: 'array', of: {type: 'string'}}) + } if (typeNode.type !== 'string') { return {type: 'null'} } @@ -347,6 +412,10 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'sanity.documentsOf': { const typeNode = walk({node: node.args[0], scope}) return mapConcrete(typeNode, scope, (typeNode) => { + if (typeNode.type === 'unknown') { + return nullUnion({type: 'array', of: {type: 'string'}}) + } + if (typeNode.type !== 'string') { return {type: 'null'} } diff --git a/src/typeEvaluator/typeEvaluate.ts b/src/typeEvaluator/typeEvaluate.ts index 2b73ee7e..21598cf4 100644 --- a/src/typeEvaluator/typeEvaluate.ts +++ b/src/typeEvaluator/typeEvaluate.ts @@ -32,7 +32,7 @@ import {handleFuncCallNode} from './functions' import {match} from './matching' import {optimizeUnions} from './optimizations' import {Context, Scope} from './scope' -import {isFuncCall, mapConcrete, mapUnion, nullUnion, resolveInline} from './typeHelpers' +import {isFuncCall, mapConcrete, nullUnion, resolveInline} from './typeHelpers' import type { ArrayTypeNode, BooleanTypeNode, @@ -124,6 +124,10 @@ function handleObjectSplatNode( const value = walk({node: attr.value, scope}) $trace('object.splat.value %O', value) return mapConcrete(value, scope, (node) => { + // splatting over unknown is unknown, we can't know what the attributes are + if (node.type === 'unknown') { + return {type: 'unknown'} + } // splatting over a non-object is a no-op if (node.type !== 'object') { return {type: 'object', attributes: {}} @@ -458,9 +462,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { $trace('opcall.node %O', node) const lhs = walk({node: node.left, scope}) const rhs = walk({node: node.right, scope}) - return mapUnion(lhs, scope, (left) => + return mapConcrete(lhs, scope, (left) => // eslint-disable-next-line complexity, max-statements - mapUnion(rhs, scope, (right) => { + mapConcrete(rhs, scope, (right) => { $trace('opcall.node.concrete "%s" %O', node.op, {left, right}) switch (node.op) { @@ -528,7 +532,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { case '<': case '<=': { if (left.type === 'unknown' || right.type === 'unknown') { - return {type: 'unknown'} + return nullUnion({type: 'boolean'}) } if (left.type !== right.type) { return {type: 'null'} satisfies NullTypeNode @@ -543,7 +547,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { } case 'in': { if (left.type === 'unknown' || right.type === 'unknown') { - return {type: 'unknown'} + return nullUnion({type: 'boolean'}) } if (right.type !== 'array') { // Special case for global::path, since it can be used with in operator, but the type returned otherwise is a string @@ -559,6 +563,10 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { } satisfies BooleanTypeNode } return mapConcrete(right.of, scope, (arrayTypeNode) => { + if (arrayTypeNode.type === 'unknown') { + return nullUnion({type: 'boolean'}) + } + if (left.type === 'null') { return { type: 'boolean', @@ -592,7 +600,8 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { } case 'match': { if (left.type === 'unknown' || right.type === 'unknown') { - return {type: 'unknown'} + // match always returns a boolean, no matter the compared types. + return {type: 'boolean'} } return { type: 'boolean', @@ -601,6 +610,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { } case '+': { if (left.type === 'unknown' || right.type === 'unknown') { + // + is ambiguous without the concrete types of the operands, so we return unknown and leave the excersise to the caller return {type: 'unknown'} } if (left.type === 'string' && right.type === 'string') { @@ -641,7 +651,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { } case '-': { if (left.type === 'unknown' || right.type === 'unknown') { - return {type: 'unknown'} + return nullUnion({type: 'number'}) } if (left.type === 'number' && right.type === 'number') { return { @@ -656,7 +666,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { } case '*': { if (left.type === 'unknown' || right.type === 'unknown') { - return {type: 'unknown'} + return nullUnion({type: 'number'}) } if (left.type === 'number' && right.type === 'number') { return { @@ -671,7 +681,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { } case '/': { if (left.type === 'unknown' || right.type === 'unknown') { - return {type: 'unknown'} + return nullUnion({type: 'number'}) } if (left.type === 'number' && right.type === 'number') { return { @@ -686,7 +696,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { } case '**': { if (left.type === 'unknown' || right.type === 'unknown') { - return {type: 'unknown'} + return nullUnion({type: 'number'}) } if (left.type === 'number' && right.type === 'number') { return { @@ -701,7 +711,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { } case '%': { if (left.type === 'unknown' || right.type === 'unknown') { - return {type: 'unknown'} + return nullUnion({type: 'number'}) } if (left.type === 'number' && right.type === 'number') { return { @@ -979,6 +989,10 @@ function handleParentNode({n}: ParentNode, scope: Scope): TypeNode { function handleNotNode(node: NotNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) return mapConcrete(base, scope, (base) => { + if (base.type === 'unknown') { + return nullUnion({type: 'boolean'}) + } + if (base.type === 'boolean') { if (base.value !== undefined) { return {type: 'boolean', value: base.value === false} @@ -993,6 +1007,10 @@ function handleNotNode(node: NotNode, scope: Scope): TypeNode { function handleNegNode(node: NegNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) return mapConcrete(base, scope, (base) => { + if (base.type === 'unknown') { + return nullUnion({type: 'number'}) + } + if (base.type !== 'number') { return {type: 'null'} } @@ -1005,6 +1023,9 @@ function handleNegNode(node: NegNode, scope: Scope): TypeNode { function handlePosNode(node: PosNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) return mapConcrete(base, scope, (base) => { + if (base.type === 'unknown') { + return nullUnion({type: 'number'}) + } if (base.type !== 'number') { return {type: 'null'} } @@ -1245,7 +1266,15 @@ function mapArray( scope: Scope, mapper: (node: ArrayTypeNode) => TypeNode, ): TypeNode { - return mapConcrete(node, scope, (base) => (base.type === 'array' ? mapper(base) : {type: 'null'})) + return mapConcrete(node, scope, (base) => { + if (base.type === 'unknown') { + return base + } + if (base.type === 'array') { + return mapper(base) + } + return {type: 'null'} + }) } function mapObject( @@ -1253,7 +1282,13 @@ function mapObject( scope: Scope, mapper: (node: ObjectTypeNode) => TypeNode, ): TypeNode { - return mapConcrete(node, scope, (base) => - base.type === 'object' ? mapper(base) : {type: 'null'}, - ) + return mapConcrete(node, scope, (base) => { + if (base.type === 'unknown') { + return base + } + if (base.type === 'object') { + return mapper(base) + } + return {type: 'null'} + }) } diff --git a/src/typeEvaluator/typeHelpers.ts b/src/typeEvaluator/typeHelpers.ts index 19953acb..6192dec1 100644 --- a/src/typeEvaluator/typeHelpers.ts +++ b/src/typeEvaluator/typeHelpers.ts @@ -96,45 +96,14 @@ export function resolveInline(node: TypeNode, scope: Scope): Exclude TypeNode, - mergeUnions: (nodes: TypeNode[]) => TypeNode = (nodes) => - optimizeUnions({type: 'union', of: nodes}), -): TypeNode { - return mapUnion( - node, - scope, - (node) => { - if (node.type === 'unknown') { - return node - } - return mapper(node) - }, - mergeUnions, - ) -} - -/** - * mapUnion extracts a _concrete type_ OR an unknown type from a type node, applies the mapping + * mapConcrete extracts a _concrete type_ OR an _unknown type node_ from a type node, applies the mapping * function to it and returns. Most notably, this will work through unions * (applying the mapping function for each variant) and inline (resolving the * reference). * This method should _only_ be used if you need to handle unknown types, ie when resolving two sides of an and node, and we don't want to abort if one side is unknown. * In most cases, you should use `mapConcrete` instead. **/ -export function mapUnion( +export function mapConcrete( node: TypeNode, scope: Scope, mapper: (node: ConcreteTypeNode | UnknownTypeNode) => T, @@ -151,10 +120,10 @@ export function mapUnion( case 'unknown': return mapper(node) case 'union': - return mergeUnions(node.of.map((inner) => mapUnion(inner, scope, mapper), mergeUnions)) + return mergeUnions(node.of.map((inner) => mapConcrete(inner, scope, mapper), mergeUnions)) case 'inline': { const resolvedInline = resolveInline(node, scope) - return mapUnion(resolvedInline, scope, mapper, mergeUnions) + return mapConcrete(resolvedInline, scope, mapper, mergeUnions) } default: // @ts-expect-error - all types should be handled From 3eb2f484d7cc4c9fdd5900694514272e255c1a4f Mon Sep 17 00:00:00 2001 From: Sindre Gulseth Date: Thu, 22 Aug 2024 20:11:19 +0200 Subject: [PATCH 4/4] chore(typeEvaluator): rename mapConcrete to mapNode --- src/typeEvaluator/functions.ts | 62 +++++++++++++++---------------- src/typeEvaluator/typeEvaluate.ts | 34 ++++++++--------- src/typeEvaluator/typeHelpers.ts | 10 ++--- 3 files changed, 52 insertions(+), 54 deletions(-) diff --git a/src/typeEvaluator/functions.ts b/src/typeEvaluator/functions.ts index eba66d01..32c564e2 100644 --- a/src/typeEvaluator/functions.ts +++ b/src/typeEvaluator/functions.ts @@ -2,7 +2,7 @@ import type {FuncCallNode} from '../nodeTypes' import {Scope} from './scope' import {walk} from './typeEvaluate' -import {mapConcrete, nullUnion} from './typeHelpers' +import {mapNode, nullUnion} from './typeHelpers' import type {NullTypeNode, TypeNode} from './types' function unionWithoutNull(unionTypeNode: TypeNode): TypeNode { @@ -21,7 +21,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'array.compact': { const arg = walk({node: node.args[0], scope}) - return mapConcrete(arg, scope, (arg) => { + return mapNode(arg, scope, (arg) => { if (arg.type === 'unknown') { return nullUnion({type: 'array', of: {type: 'unknown'}}) } @@ -29,7 +29,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { return {type: 'null'} } - const of = mapConcrete(arg.of, scope, (of) => of) + const of = mapNode(arg.of, scope, (of) => of) return { type: 'array', of: unionWithoutNull(of), @@ -41,8 +41,8 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { const arrayArg = walk({node: node.args[0], scope}) const sepArg = walk({node: node.args[1], scope}) - return mapConcrete(arrayArg, scope, (arrayArg) => - mapConcrete(sepArg, scope, (sepArg) => { + return mapNode(arrayArg, scope, (arrayArg) => + mapNode(sepArg, scope, (sepArg) => { if (arrayArg.type === 'unknown' || sepArg.type === 'unknown') { return nullUnion({type: 'string'}) } @@ -50,7 +50,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { return {type: 'null'} } - return mapConcrete(arrayArg.of, scope, (of) => { + return mapNode(arrayArg.of, scope, (of) => { if (of.type === 'unknown') { return nullUnion({type: 'string'}) } @@ -68,7 +68,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'array.unique': { const arg = walk({node: node.args[0], scope}) - return mapConcrete(arg, scope, (arg) => { + return mapNode(arg, scope, (arg) => { if (arg.type === 'unknown') { return nullUnion({type: 'array', of: {type: 'unknown'}}) } @@ -83,7 +83,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.lower': { const arg = walk({node: node.args[0], scope}) - return mapConcrete(arg, scope, (arg) => { + return mapNode(arg, scope, (arg) => { if (arg.type === 'unknown') { return nullUnion({type: 'string'}) } @@ -103,7 +103,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.upper': { const arg = walk({node: node.args[0], scope}) - return mapConcrete(arg, scope, (arg) => { + return mapNode(arg, scope, (arg) => { if (arg.type === 'unknown') { return nullUnion({type: 'string'}) } @@ -130,7 +130,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { } case 'global.path': { const arg = walk({node: node.args[0], scope}) - return mapConcrete(arg, scope, (arg) => { + return mapNode(arg, scope, (arg) => { if (arg.type === 'unknown') { return nullUnion({type: 'string'}) } @@ -168,7 +168,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.count': { const arg = walk({node: node.args[0], scope}) - return mapConcrete(arg, scope, (arg) => { + return mapNode(arg, scope, (arg) => { if (arg.type === 'unknown') { return nullUnion({type: 'string'}) } @@ -184,7 +184,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.dateTime': { const arg = walk({node: node.args[0], scope}) - return mapConcrete(arg, scope, (arg) => { + return mapNode(arg, scope, (arg) => { if (arg.type === 'unknown') { return nullUnion({type: 'string'}) } @@ -200,7 +200,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.length': { const arg = walk({node: node.args[0], scope}) - return mapConcrete(arg, scope, (arg) => { + return mapNode(arg, scope, (arg) => { if (arg.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -219,7 +219,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.round': { const numNode = walk({node: node.args[0], scope}) - return mapConcrete(numNode, scope, (num) => { + return mapNode(numNode, scope, (num) => { if (num.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -229,7 +229,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { } if (node.args.length === 2) { const precisionNode = walk({node: node.args[1], scope}) - return mapConcrete(precisionNode, scope, (precision) => { + return mapNode(precisionNode, scope, (precision) => { if (precision.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -248,7 +248,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.string': { const arg = walk({node: node.args[0], scope}) - return mapConcrete(arg, scope, (node) => { + return mapNode(arg, scope, (node) => { if (node.type === 'unknown') { return nullUnion({type: 'string'}) } @@ -272,8 +272,8 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'math.sum': { const values = walk({node: node.args[0], scope}) - // use mapConcrete to get concrete resolved value, it will also handle cases where the value is a union - return mapConcrete(values, scope, (node) => { + // use mapNode to get concrete resolved value, it will also handle cases where the value is a union + return mapNode(values, scope, (node) => { if (node.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -284,7 +284,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { } // Resolve the concrete type of the array elements - return mapConcrete(node.of, scope, (node) => { + return mapNode(node.of, scope, (node) => { if (node.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -300,8 +300,8 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'math.avg': { const values = walk({node: node.args[0], scope}) - // use mapConcrete to get concrete resolved value, it will also handle cases where the value is a union - return mapConcrete(values, scope, (node) => { + // use mapNode to get concrete resolved value, it will also handle cases where the value is a union + return mapNode(values, scope, (node) => { if (node.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -311,7 +311,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { return {type: 'null'} } // Resolve the concrete type of the array elements - return mapConcrete(node.of, scope, (node) => { + return mapNode(node.of, scope, (node) => { if (node.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -328,8 +328,8 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'math.max': case 'math.min': { const values = walk({node: node.args[0], scope}) - // use mapConcrete to get concrete resolved value, it will also handle cases where the value is a union - return mapConcrete(values, scope, (node) => { + // use mapNode to get concrete resolved value, it will also handle cases where the value is a union + return mapNode(values, scope, (node) => { if (node.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -340,7 +340,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { } // Resolve the concrete type of the array elements - return mapConcrete(node.of, scope, (node) => { + return mapNode(node.of, scope, (node) => { if (node.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -366,8 +366,8 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'string.startsWith': { const strTypeNode = walk({node: node.args[0], scope}) const prefixTypeNode = walk({node: node.args[1], scope}) - return mapConcrete(strTypeNode, scope, (strNode) => { - return mapConcrete(prefixTypeNode, scope, (prefixNode) => { + return mapNode(strTypeNode, scope, (strNode) => { + return mapNode(prefixTypeNode, scope, (prefixNode) => { if (strNode.type === 'unknown' || prefixNode.type === 'unknown') { return nullUnion({type: 'boolean'}) } @@ -383,8 +383,8 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'string.split': { const strTypeNode = walk({node: node.args[0], scope}) const sepTypeNode = walk({node: node.args[1], scope}) - return mapConcrete(strTypeNode, scope, (strNode) => { - return mapConcrete(sepTypeNode, scope, (sepNode) => { + return mapNode(strTypeNode, scope, (strNode) => { + return mapNode(sepTypeNode, scope, (sepNode) => { if (strNode.type === 'unknown' || sepNode.type === 'unknown') { return nullUnion({type: 'array', of: {type: 'string'}}) } @@ -399,7 +399,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { } case 'sanity.versionOf': { const typeNode = walk({node: node.args[0], scope}) - return mapConcrete(typeNode, scope, (typeNode) => { + return mapNode(typeNode, scope, (typeNode) => { if (typeNode.type === 'unknown') { return nullUnion({type: 'array', of: {type: 'string'}}) } @@ -411,7 +411,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { } case 'sanity.documentsOf': { const typeNode = walk({node: node.args[0], scope}) - return mapConcrete(typeNode, scope, (typeNode) => { + return mapNode(typeNode, scope, (typeNode) => { if (typeNode.type === 'unknown') { return nullUnion({type: 'array', of: {type: 'string'}}) } diff --git a/src/typeEvaluator/typeEvaluate.ts b/src/typeEvaluator/typeEvaluate.ts index 21598cf4..27e032ae 100644 --- a/src/typeEvaluator/typeEvaluate.ts +++ b/src/typeEvaluator/typeEvaluate.ts @@ -32,7 +32,7 @@ import {handleFuncCallNode} from './functions' import {match} from './matching' import {optimizeUnions} from './optimizations' import {Context, Scope} from './scope' -import {isFuncCall, mapConcrete, nullUnion, resolveInline} from './typeHelpers' +import {isFuncCall, mapNode, nullUnion, resolveInline} from './typeHelpers' import type { ArrayTypeNode, BooleanTypeNode, @@ -123,7 +123,7 @@ function handleObjectSplatNode( ): TypeNode { const value = walk({node: attr.value, scope}) $trace('object.splat.value %O', value) - return mapConcrete(value, scope, (node) => { + return mapNode(value, scope, (node) => { // splatting over unknown is unknown, we can't know what the attributes are if (node.type === 'unknown') { return {type: 'unknown'} @@ -258,7 +258,7 @@ function handleObjectNode(node: ObjectNode, scope: Scope): TypeNode { } } - const variant = mapConcrete(attributeNode, scope, (attributeNode) => { + const variant = mapNode(attributeNode, scope, (attributeNode) => { $trace('object.conditional.splat.result.concrete %O', attributeNode) if (attributeNode.type !== 'object') { return {type: 'unknown'} @@ -462,9 +462,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { $trace('opcall.node %O', node) const lhs = walk({node: node.left, scope}) const rhs = walk({node: node.right, scope}) - return mapConcrete(lhs, scope, (left) => + return mapNode(lhs, scope, (left) => // eslint-disable-next-line complexity, max-statements - mapConcrete(rhs, scope, (right) => { + mapNode(rhs, scope, (right) => { $trace('opcall.node.concrete "%s" %O', node.op, {left, right}) switch (node.op) { @@ -562,7 +562,7 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { value: false, } satisfies BooleanTypeNode } - return mapConcrete(right.of, scope, (arrayTypeNode) => { + return mapNode(right.of, scope, (arrayTypeNode) => { if (arrayTypeNode.type === 'unknown') { return nullUnion({type: 'boolean'}) } @@ -773,7 +773,7 @@ function handleFlatMap(node: FlatMapNode, scope: Scope): TypeNode { return mapArray(base, scope, (base) => { const inner = walk({node: node.expr, scope: scope.createHidden([base.of])}) - return mapConcrete( + return mapNode( inner, scope, (inner) => { @@ -835,7 +835,7 @@ function handleFilterNode(node: FilterNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) $trace('filter.base %O', base) - return mapConcrete(base, scope, (base) => { + return mapNode(base, scope, (base) => { $trace('filter.resolving %O', base) if (base.type === 'null') { return base @@ -988,7 +988,7 @@ function handleParentNode({n}: ParentNode, scope: Scope): TypeNode { function handleNotNode(node: NotNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) - return mapConcrete(base, scope, (base) => { + return mapNode(base, scope, (base) => { if (base.type === 'unknown') { return nullUnion({type: 'boolean'}) } @@ -1006,7 +1006,7 @@ function handleNotNode(node: NotNode, scope: Scope): TypeNode { function handleNegNode(node: NegNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) - return mapConcrete(base, scope, (base) => { + return mapNode(base, scope, (base) => { if (base.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -1022,7 +1022,7 @@ function handleNegNode(node: NegNode, scope: Scope): TypeNode { } function handlePosNode(node: PosNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) - return mapConcrete(base, scope, (base) => { + return mapNode(base, scope, (base) => { if (base.type === 'unknown') { return nullUnion({type: 'number'}) } @@ -1051,8 +1051,8 @@ function handleEverythingNode(_: EverythingNode, scope: Scope): TypeNode { function handleAndNode(node: AndNode, scope: Scope): TypeNode { const left = walk({node: node.left, scope}) const right = walk({node: node.right, scope}) - return mapConcrete(left, scope, (lhs) => - mapConcrete(right, scope, (rhs) => { + return mapNode(left, scope, (lhs) => + mapNode(right, scope, (rhs) => { const value = booleanAnd(booleanValue(lhs, scope), booleanValue(rhs, scope)) return booleanInterpretationToTypeNode(value) @@ -1063,8 +1063,8 @@ function handleAndNode(node: AndNode, scope: Scope): TypeNode { function handleOrNode(node: OrNode, scope: Scope): TypeNode { const left = walk({node: node.left, scope}) const right = walk({node: node.right, scope}) - return mapConcrete(left, scope, (lhs) => - mapConcrete(right, scope, (rhs) => { + return mapNode(left, scope, (lhs) => + mapNode(right, scope, (rhs) => { const value = booleanOr(booleanValue(lhs, scope), booleanValue(rhs, scope)) return booleanInterpretationToTypeNode(value) @@ -1266,7 +1266,7 @@ function mapArray( scope: Scope, mapper: (node: ArrayTypeNode) => TypeNode, ): TypeNode { - return mapConcrete(node, scope, (base) => { + return mapNode(node, scope, (base) => { if (base.type === 'unknown') { return base } @@ -1282,7 +1282,7 @@ function mapObject( scope: Scope, mapper: (node: ObjectTypeNode) => TypeNode, ): TypeNode { - return mapConcrete(node, scope, (base) => { + return mapNode(node, scope, (base) => { if (base.type === 'unknown') { return base } diff --git a/src/typeEvaluator/typeHelpers.ts b/src/typeEvaluator/typeHelpers.ts index 6192dec1..b35a0c2a 100644 --- a/src/typeEvaluator/typeHelpers.ts +++ b/src/typeEvaluator/typeHelpers.ts @@ -96,14 +96,12 @@ export function resolveInline(node: TypeNode, scope: Scope): Exclude( +export function mapNode( node: TypeNode, scope: Scope, mapper: (node: ConcreteTypeNode | UnknownTypeNode) => T, @@ -120,10 +118,10 @@ export function mapConcrete( case 'unknown': return mapper(node) case 'union': - return mergeUnions(node.of.map((inner) => mapConcrete(inner, scope, mapper), mergeUnions)) + return mergeUnions(node.of.map((inner) => mapNode(inner, scope, mapper), mergeUnions)) case 'inline': { const resolvedInline = resolveInline(node, scope) - return mapConcrete(resolvedInline, scope, mapper, mergeUnions) + return mapNode(resolvedInline, scope, mapper, mergeUnions) } default: // @ts-expect-error - all types should be handled