From f00518d13d94abd58592218006c67e5ffe865501 Mon Sep 17 00:00:00 2001 From: Sindre Gulseth Date: Thu, 22 Aug 2024 15:51:09 +0200 Subject: [PATCH] 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 e386bfb..eba66d0 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 2b73ee7..21598cf 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 19953ac..6192dec 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