Skip to content

Commit

Permalink
refactor(typeEvaluator): use boolean interpretation to resolve condit…
Browse files Browse the repository at this point in the history
…ions

feat(typeEvaluator): separate booleans logic into functions
  • Loading branch information
sgulseth committed Aug 23, 2024
1 parent b8ce862 commit b7d235d
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 77 deletions.
131 changes: 131 additions & 0 deletions src/typeEvaluator/booleans.ts
Original file line number Diff line number Diff line change
@@ -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'}
}
89 changes: 12 additions & 77 deletions src/typeEvaluator/typeEvaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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)
}),
)
}
Expand All @@ -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)
}),
)
}
Expand Down Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions test/typeEvaluate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,7 @@ t.test('values in projection', (t) => {
type: 'objectAttribute',
value: nullUnion({
type: 'boolean',
value: true,
}),
},
},
Expand Down

0 comments on commit b7d235d

Please sign in to comment.