diff --git a/packages/typir/src/kinds/function/function-initializer.ts b/packages/typir/src/kinds/function/function-initializer.ts index 13058f3..aa20386 100644 --- a/packages/typir/src/kinds/function/function-initializer.ts +++ b/packages/typir/src/kinds/function/function-initializer.ts @@ -6,12 +6,13 @@ import { isType, Type, TypeStateListener } from '../../graph/type-node.js'; import { TypeInitializer } from '../../initialization/type-initializer.js'; -import { CompositeTypeInferenceRule, InferenceProblem, InferenceRuleNotApplicable, TypeInferenceRule } from '../../services/inference.js'; +import { AssignabilitySuccess, isAssignabilityProblem } from '../../services/assignability.js'; +import { CompositeTypeInferenceRule, InferenceProblem, InferenceRuleNotApplicable, TypeInferenceRule, TypeInferenceRuleWithInferringChildren } from '../../services/inference.js'; import { ValidationRule } from '../../services/validation.js'; import { TypirServices } from '../../typir.js'; import { checkTypeArrays } from '../../utils/utils-type-comparison.js'; import { assertType } from '../../utils/utils.js'; -import { CreateFunctionTypeDetails, FunctionKind } from './function-kind.js'; +import { CreateFunctionTypeDetails, FunctionKind, OverloadedFunctionDetails } from './function-kind.js'; import { FunctionType, isFunctionType } from './function-type.js'; export class FunctionTypeInitializer extends TypeInitializer implements TypeStateListener { @@ -38,13 +39,12 @@ export class FunctionTypeInitializer extends TypeInitializer im } // prepare the overloads - let overloaded = this.kind.mapNameTypes.get(functionName); - if (overloaded) { + if (this.kind.mapNameTypes.has(functionName)) { // do nothing } else { - overloaded = { + const overloaded: OverloadedFunctionDetails = { overloadedFunctions: [], - inference: new CompositeTypeInferenceRule(this.services), + inference: new OverloadedFunctionsTypeInferenceRule(this.services), sameOutputType: undefined, }; this.kind.mapNameTypes.set(functionName, overloaded); @@ -135,85 +135,14 @@ export class FunctionTypeInitializer extends TypeInitializer im protected createInferenceRules(typeDetails: CreateFunctionTypeDetails, functionType: FunctionType): FunctionInferenceRules { const result: FunctionInferenceRules = {}; - const functionName = typeDetails.functionName; const mapNameTypes = this.kind.mapNameTypes; - const outputTypeForFunctionCalls = this.kind.getOutputTypeForFunctionCalls(functionType); - if (typeDetails.inferenceRuleForCalls) { - /** Preconditions: - * - there is a rule which specifies how to infer the current function type - * - the current function has an output type/parameter, otherwise, this function could not provide any type (and throws an error), when it is called! - * (exception: the options contain a type to return in this special case) - */ - function check(returnType: Type | undefined): Type { - if (returnType) { // this condition is checked here, since 'undefined' is OK, as long as it is not used; extracting this function is difficult due to TypeScripts strict rules for using 'this' - return returnType; - } else { - throw new Error(`The function ${functionName} is called, but has no output type to infer.`); - } - } - // register inference rule for calls of the new function - // TODO what about the case, that multiple variants match?? after implicit conversion for example?! => overload with the lowest number of conversions wins! - result.inferenceForCall = { - inferTypeWithoutChildren(languageNode, _typir) { - const result = typeDetails.inferenceRuleForCalls!.filter(languageNode); - if (result) { - const matching = typeDetails.inferenceRuleForCalls!.matching(languageNode); - if (matching) { - const inputArguments = typeDetails.inferenceRuleForCalls!.inputArguments(languageNode); - if (inputArguments && inputArguments.length >= 1) { - // this function type might match, to be sure, resolve the types of the values for the parameters and continue to step 2 - const overloadInfos = mapNameTypes.get(functionName); - if (overloadInfos && overloadInfos.overloadedFunctions.length >= 2) { - // (only) for overloaded functions: - if (overloadInfos.sameOutputType) { - // exception: all(!) overloaded functions have the same(!) output type, save performance and return this type! - return overloadInfos.sameOutputType; - } else { - // otherwise: the types of the parameters need to be inferred in order to determine an exact match - return inputArguments; - } - } else { - // the current function is not overloaded, therefore, the types of their parameters are not required => save time, ignore inference errors - return check(outputTypeForFunctionCalls); - } - } else { - // there are no operands to check - return check(outputTypeForFunctionCalls); - } - } else { - // the language node is slightly different - } - } else { - // the language node has a completely different purpose - } - // does not match at all - return InferenceRuleNotApplicable; - }, - inferTypeWithChildrensTypes(languageNode, actualInputTypes, typir) { - const expectedInputTypes = typeDetails.inputParameters.map(p => typir.infrastructure.TypeResolver.resolve(p.type)); - // all operands need to be assignable(! not equal) to the required types - const comparisonConflicts = checkTypeArrays(actualInputTypes, expectedInputTypes, - (t1, t2) => typir.Assignability.getAssignabilityProblem(t1, t2), true); - if (comparisonConflicts.length >= 1) { - // this function type does not match, due to assignability conflicts => return them as errors - return { - $problem: InferenceProblem, - languageNode: languageNode, - inferenceCandidate: functionType, - location: 'input parameters', - rule: this, - subProblems: comparisonConflicts, - }; - // We have a dedicated validation for this case (see below), but a resulting error might be ignored by the user => return the problem during type-inference again - } else { - // matching => return the return type of the function for the case of a function call! - return check(outputTypeForFunctionCalls); - } - }, - }; + // create inference rule for calls of the new function + if (typeDetails.inferenceRuleForCalls) { + result.inferenceForCall = new FunctionCallInferenceRule(typeDetails, functionType, mapNameTypes); } + // create validation for checking the assignability of arguments to input paramters if (typeDetails.validationForCall) { result.validationForCall = (languageNode, typir) => { if (typeDetails.inferenceRuleForCalls!.filter(languageNode) && typeDetails.inferenceRuleForCalls!.matching(languageNode)) { @@ -221,7 +150,7 @@ export class FunctionTypeInitializer extends TypeInitializer im const inputArguments = typeDetails.inferenceRuleForCalls!.inputArguments(languageNode); if (inputArguments && inputArguments.length >= 1) { // this function type might match, to be sure, resolve the types of the values for the parameters and continue to step 2 - const overloadInfos = mapNameTypes.get(functionName); + const overloadInfos = mapNameTypes.get(typeDetails.functionName); if (overloadInfos && overloadInfos.overloadedFunctions.length >= 2) { // for overloaded functions: the types of the parameters need to be inferred in order to determine an exact match // (Note that the short-cut for type inference for function calls, when all overloads return the same output type, does not work here, since the validation here is specific for this single variant!) @@ -253,7 +182,7 @@ export class FunctionTypeInitializer extends TypeInitializer im }; } - // register inference rule for the declaration of the new function + // create inference rule for the declaration of the new function // (regarding overloaded function, for now, it is assumed, that the given inference rule itself is concrete enough to handle overloaded functions itself!) if (typeDetails.inferenceRuleForDeclaration) { result.inferenceForDeclaration = (languageNode, _typir) => { @@ -275,3 +204,207 @@ interface FunctionInferenceRules { validationForCall?: ValidationRule; inferenceForDeclaration?: TypeInferenceRule; } + + +/** Preconditions: + * - there is a rule which specifies how to infer the current function type + * - the current function has an output type/parameter, otherwise, this function could not provide any type (and throws an error), when it is called! + * (exception: the options contain a type to return in this special case) + */ +class FunctionCallInferenceRule implements TypeInferenceRuleWithInferringChildren { + protected readonly typeDetails: CreateFunctionTypeDetails; + protected readonly functionType: FunctionType; + protected readonly mapNameTypes: Map; + assignabilitySuccess: Array; + + constructor(typeDetails: CreateFunctionTypeDetails, functionType: FunctionType, mapNameTypes: Map) { + this.typeDetails = typeDetails; + this.functionType = functionType; + this.mapNameTypes = mapNameTypes; + this.assignabilitySuccess = new Array(typeDetails.inputParameters.length); + } + + inferTypeWithoutChildren(languageNode: unknown, _typir: TypirServices): unknown { + this.assignabilitySuccess.fill(undefined); // reset the entries + const result = this.typeDetails.inferenceRuleForCalls!.filter(languageNode); + if (result) { + const matching = this.typeDetails.inferenceRuleForCalls!.matching(languageNode); + if (matching) { + const inputArguments = this.typeDetails.inferenceRuleForCalls!.inputArguments(languageNode); + if (inputArguments && inputArguments.length >= 1) { + // this function type might match, to be sure, resolve the types of the values for the parameters and continue to step 2 + const overloadInfos = this.mapNameTypes.get(this.typeDetails.functionName); + if (overloadInfos && overloadInfos.overloadedFunctions.length >= 2) { + // (only) for overloaded functions: + if (overloadInfos.sameOutputType) { + // exception: all(!) overloaded functions have the same(!) output type, save performance and return this type! + return overloadInfos.sameOutputType; + } else { + // otherwise: the types of the parameters need to be inferred in order to determine an exact match + return inputArguments; + } + } else { + // the current function is not overloaded, therefore, the types of their parameters are not required => save time, ignore inference errors + return this.check(this.getOutputTypeForFunctionCalls()); + } + } else { + // there are no operands to check + return this.check(this.getOutputTypeForFunctionCalls()); + } + } else { + // the language node is slightly different + } + } else { + // the language node has a completely different purpose + } + // does not match at all + return InferenceRuleNotApplicable; + } + + inferTypeWithChildrensTypes(languageNode: unknown, actualInputTypes: Array, typir: TypirServices): Type | InferenceProblem { + const expectedInputTypes = this.typeDetails.inputParameters.map(p => typir.infrastructure.TypeResolver.resolve(p.type)); + // all operands need to be assignable(! not equal) to the required types + const comparisonConflicts = checkTypeArrays( + actualInputTypes, + expectedInputTypes, + (t1, t2, index) => { + const result = typir.Assignability.getAssignabilityResult(t1, t2); + if (isAssignabilityProblem(result)) { + return result; + } else { + // save the information equal/conversion/subtype for deciding "conflicts" of overloaded functions + this.assignabilitySuccess[index] = result; + return undefined; + } + }, + true, + ); + if (comparisonConflicts.length >= 1) { + // this function type does not match, due to assignability conflicts => return them as errors + return { + $problem: InferenceProblem, + languageNode: languageNode, + inferenceCandidate: this.functionType, + location: 'input parameters', + rule: this, + subProblems: comparisonConflicts, + }; + // We have a dedicated validation for this case (see below), but a resulting error might be ignored by the user => return the problem during type-inference again + } else { + // matching => return the return type of the function for the case of a function call! + return this.check(this.getOutputTypeForFunctionCalls()); + } + } + + protected getOutputTypeForFunctionCalls(): Type | undefined { + return this.functionType.kind.getOutputTypeForFunctionCalls(this.functionType); + } + + protected check(returnType: Type | undefined): Type { + if (returnType) { // this condition is checked here, since 'undefined' is OK, as long as it is not used; extracting this function is difficult due to TypeScripts strict rules for using 'this' + return returnType; + } else { + throw new Error(`The function ${this.typeDetails.functionName} is called, but has no output type to infer.`); + } + } +} + + +export class OverloadedFunctionsTypeInferenceRule extends CompositeTypeInferenceRule { + + protected override inferTypeLogic(languageNode: unknown): Type | InferenceProblem[] { + this.checkForError(languageNode); + + // check all rules in order to search for the best-matching rule, not for the first-matching rule + const matchingOverloads: OverloadedMatch[] = []; + const collectedInferenceProblems: InferenceProblem[] = []; + for (const rules of this.inferenceRules.values()) { + for (const rule of rules) { + const result = this.executeSingleInferenceRuleLogic(rule, languageNode, collectedInferenceProblems); + if (result) { + matchingOverloads.push({ result, rule: rule as FunctionCallInferenceRule }); + } else { + // no result for this inference rule => check the next inference rules + } + } + } + + if (matchingOverloads.length <= 0) { + // no matches => return all the collected inference problems + if (collectedInferenceProblems.length <= 0) { + // document the reason, why neither a type nor inference problems are found + collectedInferenceProblems.push({ + $problem: InferenceProblem, + languageNode: languageNode, + location: 'found no applicable inference rules', + subProblems: [], + }); + } + return collectedInferenceProblems; + } else if (matchingOverloads.length === 1) { + // single match + return matchingOverloads[0].result; + } else { + // multiple matches => determine the one to return + + // 1. sort the found matches + matchingOverloads.sort((l, r) => this.compareMatchingOverloads(l, r)); + + // 2. identify the best matches at the beginning of the list + let index = 1; + while (index < matchingOverloads.length) { + if (this.compareMatchingOverloads(matchingOverloads[index - 1], matchingOverloads[index]) === 0) { + index++; // same priority + } else { + break; // lower priority => skip them + } + } + matchingOverloads.splice(index); // keep only the matches with the same highest priority + // TODO review: should we make this implementation more efficient? + + // 3. evaluate remaining best matches + if (matchingOverloads.length === 0) { + // return the single remaining match + return matchingOverloads[0].result; + } else { + // decide how to deal with multiple best matches + const result = this.handleMultipleBestMatches(matchingOverloads); + if (result) { + // return the chosen match + return result.result; + } else { + // no decision => inference is not possible + return [{ + $problem: InferenceProblem, + languageNode: languageNode, + location: `Found ${matchingOverloads.length} best matching overloads: ${matchingOverloads.map(m => m.result.getIdentifier()).join(', ')}`, + subProblems: [], // there are no real sub-problems, since the relevant overloads match ... + }]; + } + } + } + } + + protected handleMultipleBestMatches(matchingOverloads: OverloadedMatch[]): OverloadedMatch | undefined { + return matchingOverloads[0]; // by default, return the 1st best match + } + + // better matches are at the beginning of the list, i.e. better matches get values lower than zero + protected compareMatchingOverloads(match1: OverloadedMatch, match2: OverloadedMatch): number { + const cost1 = this.calculateCost(match1); + const cost2 = this.calculateCost(match2); + return cost1 === cost2 ? 0 : cost1 < cost2 ? -1 : +1; + } + + protected calculateCost(match: OverloadedMatch): number { + return match.rule.assignabilitySuccess + // equal types are better than sub-types, sub-types are better than conversions + .map(value => (value === 'EQUAL' ? 0 : value === 'SUB_TYPE' ? 1 : 2) as number) + .reduce((l, r) => l + r, 0); + } +} + +interface OverloadedMatch { + result: Type; + rule: FunctionCallInferenceRule; +} diff --git a/packages/typir/src/kinds/function/function-kind.ts b/packages/typir/src/kinds/function/function-kind.ts index 2a62629..5edbed1 100644 --- a/packages/typir/src/kinds/function/function-kind.ts +++ b/packages/typir/src/kinds/function/function-kind.ts @@ -10,13 +10,12 @@ import { Type, TypeDetails } from '../../graph/type-node.js'; import { TypeInitializer } from '../../initialization/type-initializer.js'; import { TypeReference } from '../../initialization/type-reference.js'; import { TypeSelector } from '../../initialization/type-selector.js'; -import { CompositeTypeInferenceRule } from '../../services/inference.js'; import { ValidationProblem } from '../../services/validation.js'; import { TypirServices } from '../../typir.js'; import { NameTypePair } from '../../utils/utils-definitions.js'; import { TypeCheckStrategy, checkTypes, checkValueForConflict, createTypeCheckStrategy } from '../../utils/utils-type-comparison.js'; import { Kind, isKind } from '../kind.js'; -import { FunctionTypeInitializer } from './function-initializer.js'; +import { FunctionTypeInitializer, OverloadedFunctionsTypeInferenceRule } from './function-initializer.js'; import { FunctionType, isFunctionType } from './function-type.js'; @@ -59,12 +58,15 @@ export interface CreateFunctionTypeDetails extends FunctionTypeDetails { validationForCall?: FunctionCallValidationRule, } -/** Collects all functions with the same name */ -interface OverloadedFunctionDetails { +/** + * Collects information about all functions with the same name. + * This is required to handle overloaded functions. + */ +export interface OverloadedFunctionDetails { // eslint-disable-next-line @typescript-eslint/no-explicit-any overloadedFunctions: Array>; - inference: CompositeTypeInferenceRule; // collects the inference rules for all functions with the same name - sameOutputType: Type | undefined; // if all overloaded functions with the same name have the same output/return type, this type is remembered here + inference: OverloadedFunctionsTypeInferenceRule; // collects the inference rules for all functions with the same name + sameOutputType: Type | undefined; // if all overloaded functions with the same name have the same output/return type, this type is remembered here (for performance optimization) } interface SingleFunctionDetails { diff --git a/packages/typir/src/services/inference.ts b/packages/typir/src/services/inference.ts index c544cce..2dba796 100644 --- a/packages/typir/src/services/inference.ts +++ b/packages/typir/src/services/inference.ts @@ -231,11 +231,11 @@ export class DefaultTypeInferenceCollector implements TypeInferenceCollector, Ty // this rule might match => continue applying this rule // resolve the requested child types const childLanguageNodes = ruleResult; - const childTypes: Array = childLanguageNodes.map(child => this.services.Inference.inferType(child)); + const actualChildTypes: Array = childLanguageNodes.map(child => this.services.Inference.inferType(child)); // check, whether inferring the children resulted in some other inference problems const childTypeProblems: InferenceProblem[] = []; - for (let i = 0; i < childTypes.length; i++) { - const child = childTypes[i]; + for (let i = 0; i < actualChildTypes.length; i++) { + const child = actualChildTypes[i]; if (Array.isArray(child)) { childTypeProblems.push({ $problem: InferenceProblem, @@ -257,7 +257,7 @@ export class DefaultTypeInferenceCollector implements TypeInferenceCollector, Ty return undefined; } else { // the types of all children are successfully inferred - const finalInferenceResult = rule.inferTypeWithChildrensTypes(languageNode, childTypes as Type[], this.services); + const finalInferenceResult = rule.inferTypeWithChildrensTypes(languageNode, actualChildTypes as Type[], this.services); if (isType(finalInferenceResult)) { // type is inferred! return finalInferenceResult; @@ -280,7 +280,7 @@ export class DefaultTypeInferenceCollector implements TypeInferenceCollector, Ty // this rule is not applicable at all => ignore this rule return undefined; } else if (isType(result)) { - // the result type is already found! + // the result type is found! return result; } else if (isInferenceProblem(result)) { // found some inference problems diff --git a/packages/typir/test/kinds/function/operator-overloaded.test.ts b/packages/typir/test/kinds/function/operator-overloaded.test.ts new file mode 100644 index 0000000..d0eec16 --- /dev/null +++ b/packages/typir/test/kinds/function/operator-overloaded.test.ts @@ -0,0 +1,134 @@ +/****************************************************************************** + * Copyright 2024 TypeFox GmbH + * This program and the accompanying materials are made available under the + * terms of the MIT License, which is available in the project root. + ******************************************************************************/ + +/* eslint-disable @typescript-eslint/parameter-properties */ + +import { beforeAll, describe, expect, test } from 'vitest'; +import { AssignmentStatement, BinaryExpression, DoubleLiteral, InferenceRuleBinaryExpression, IntegerLiteral, StringLiteral, TestExpressionNode, Variable } from '../../../src/test/predefined-language-nodes.js'; +import { createTypirServicesForTesting, expectType } from '../../../src/utils/test-utils.js'; +import { InferenceRuleNotApplicable} from '../../../src/services/inference.js'; +import { ValidationMessageDetails } from '../../../src/services/validation.js'; +import { TypirServices } from '../../../src/typir.js'; +import { isPrimitiveType } from '../../../src/index.js'; + +describe('Multiple best matches for overloaded operators', () => { + let typir: TypirServices; + + beforeAll(() => { + typir = createTypirServicesForTesting(); + + // primitive types + const integerType = typir.factory.Primitives.create({ primitiveName: 'integer', inferenceRules: node => node instanceof IntegerLiteral }); + const doubleType = typir.factory.Primitives.create({ primitiveName: 'double', inferenceRules: node => node instanceof DoubleLiteral }); + const stringType = typir.factory.Primitives.create({ primitiveName: 'string', inferenceRules: node => node instanceof StringLiteral }); + + // operators + typir.factory.Operators.createBinary({ name: '+', signatures: [ // operator overloading + { left: integerType, right: integerType, return: integerType }, // 2 + 3 => 5 + { left: doubleType, right: doubleType, return: doubleType }, // 2.0 + 3.0 => 5.0 + { left: stringType, right: stringType, return: stringType }, // "2" + "3" => "23" + ], inferenceRule: InferenceRuleBinaryExpression }); + + // define relationships between types + typir.Conversion.markAsConvertible(doubleType, stringType, 'IMPLICIT_EXPLICIT'); // stringVariable := doubleValue; + typir.Subtype.markAsSubType(integerType, doubleType); // double <|--- integer + + // specify, how Typir can detect the type of a variable + typir.Inference.addInferenceRule(node => { + if (node instanceof Variable) { + return node.initialValue; // the type of the variable is the type of its initial value + } + return InferenceRuleNotApplicable; + }); + + // register a type-related validation + typir.validation.Collector.addValidationRule(node => { + if (node instanceof AssignmentStatement) { + return typir.validation.Constraints.ensureNodeIsAssignable(node.right, node.left, (actual, expected) => { message: + `The type '${actual.name}' is not assignable to the type '${expected.name}'.` }); + } + return []; + }); + }); + + test('2 + 3 => OK (both are integers)', () => { + expectOverload(new IntegerLiteral(2), new IntegerLiteral(3), 'integer'); + }); + + test('2.0 + 3.0 => OK (both are doubles)', () => { + expectOverload(new DoubleLiteral(2.0), new DoubleLiteral(3.0), 'double'); + }); + + test('"2" + "3" => OK (both are strings)', () => { + expectOverload(new StringLiteral('2'), new StringLiteral('3'), 'string'); + }); + + test('2.0 + 3 => OK (integers are doubles)', () => { + expectOverload(new DoubleLiteral(2.0), new IntegerLiteral(3), 'double'); + }); + + test('2.0 + "3" => OK (convert double to string)', () => { + expectOverload(new DoubleLiteral(2.0), new StringLiteral('3'), 'string'); + }); + + test('2 + "3" => OK (integer is sub-type of double, which is convertible to string)', () => { + expectOverload(new IntegerLiteral(2), new StringLiteral('3'), 'string'); + }); + + function expectOverload(left: TestExpressionNode, right: TestExpressionNode, typeName: 'string'|'integer'|'double'): void { + const example = new BinaryExpression(left, '+', right); + expect(typir.validation.Collector.validate(example)).toHaveLength(0); + expectType(typir.Inference.inferType(example), isPrimitiveType, type => type.getName() === typeName); + } + + + // tests all cases for assignability + + test('integer to integer', () => { + expectAssignmentValid(new IntegerLiteral(123), new IntegerLiteral(456)); + }); + test('double to integer', () => { + expectAssignmentError(new DoubleLiteral(123.0), new IntegerLiteral(456)); + }); + test('string to integer', () => { + expectAssignmentError(new StringLiteral('123'), new IntegerLiteral(456)); + }); + + test('integer to double', () => { + expectAssignmentValid(new IntegerLiteral(123), new DoubleLiteral(456.0)); + }); + test('double to double', () => { + expectAssignmentValid(new DoubleLiteral(123.0), new DoubleLiteral(456.0)); + }); + test('string to double', () => { + expectAssignmentError(new StringLiteral('123'), new DoubleLiteral(456.0)); + }); + + test('integer to string', () => { + expectAssignmentValid(new IntegerLiteral(123), new StringLiteral('456')); + }); + test('double to string', () => { + expectAssignmentValid(new DoubleLiteral(123.0), new StringLiteral('456')); + }); + test('string to string', () => { + expectAssignmentValid(new StringLiteral('123'), new StringLiteral('456')); + }); + + function expectAssignmentValid(value: TestExpressionNode, variableInitType: TestExpressionNode): void { + const variable = new Variable('v1', variableInitType); + const assignment = new AssignmentStatement(variable, value); + expect(typir.validation.Collector.validate(assignment)).toHaveLength(0); + } + + function expectAssignmentError(value: TestExpressionNode, variableInitType: TestExpressionNode): void { + const variable = new Variable('v1', variableInitType); + const assignment = new AssignmentStatement(variable, value); + const errors = typir.validation.Collector.validate(assignment); + expect(errors).toHaveLength(1); + expect(errors[0].message).includes('is not assignable to'); + } +}); +