diff --git a/src/typeEvaluator/booleans.ts b/src/typeEvaluator/booleans.ts new file mode 100644 index 0000000..9864672 --- /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 e1a3f1c..2b73ee7 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 56e44b5..a3f9dd1 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, }), }, },