From 03d4fca5c0a99130616ccc2ae86ae30c6bee4bff Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 6 Sep 2024 14:07:18 -0700 Subject: [PATCH] Improvements to type relation errors (#4357) fix https://github.com/microsoft/typespec/issues/3291 Changes: 1. Figure out the most accurate location for the diagnostic 2. If diagnostic target a child node of the base diagnostic target then emit diagnostic directly there 3. Otherwise emit back at the root(or closest child node) and build stack of error message Example the following would now emit the error on a ```ts const b = #{ prop: #{a: "abc"}}; const a: {prop: {}} = b; ``` ``` Type '{ prop: { a: "abc" } }' is not assignable to type '{ prop: {} }' Type '{ a: "abc" }' is not assignable to type '{}' Object value may only specify known properties, and 'a' does not exist in type '{}'. ``` Previously the error would have been in the complete wrong place image --- ...relation-improvements-2024-8-6-13-13-15.md | 8 + packages/compiler/src/core/checker.ts | 12 +- packages/compiler/src/core/messages.ts | 9 +- .../src/core/type-relation-checker.ts | 366 ++++++++++++------ packages/compiler/src/lib/service.ts | 4 +- .../compiler/test/checker/relation.test.ts | 125 +++++- 6 files changed, 388 insertions(+), 136 deletions(-) create mode 100644 .chronus/changes/type-relation-improvements-2024-8-6-13-13-15.md diff --git a/.chronus/changes/type-relation-improvements-2024-8-6-13-13-15.md b/.chronus/changes/type-relation-improvements-2024-8-6-13-13-15.md new file mode 100644 index 0000000000..f1937efc80 --- /dev/null +++ b/.chronus/changes/type-relation-improvements-2024-8-6-13-13-15.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Improvements to type relation errors: Show stack when it happens in a nested property otherwise show up in the correct location. diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index a8921b6af5..4ba4013550 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -5649,7 +5649,7 @@ export function createChecker(program: Program): Checker { function checkArgumentAssignable( argumentType: Type | Value | IndeterminateEntity, parameterType: Entity, - diagnosticTarget: DiagnosticTarget + diagnosticTarget: Entity | Node ): boolean { const [valid] = relation.isTypeAssignableTo(argumentType, parameterType, diagnosticTarget); if (!valid) { @@ -7420,7 +7420,7 @@ export function createChecker(program: Program): Checker { function checkTypeOfValueMatchConstraint( source: Entity, constraint: CheckValueConstraint, - diagnosticTarget: DiagnosticTarget + diagnosticTarget: Entity | Node ): boolean { const [related, diagnostics] = relation.isTypeAssignableTo( source, @@ -7455,7 +7455,7 @@ export function createChecker(program: Program): Checker { function checkTypeAssignable( source: Entity | IndeterminateEntity, target: Entity, - diagnosticTarget: DiagnosticTarget + diagnosticTarget: Entity | Node ): boolean { const [related, diagnostics] = relation.isTypeAssignableTo(source, target, diagnosticTarget); if (!related) { @@ -7464,11 +7464,7 @@ export function createChecker(program: Program): Checker { return related; } - function checkValueOfType( - source: Value, - target: Type, - diagnosticTarget: DiagnosticTarget - ): boolean { + function checkValueOfType(source: Value, target: Type, diagnosticTarget: Entity | Node): boolean { const [related, diagnostics] = relation.isValueOfType(source, target, diagnosticTarget); if (!related) { reportCheckerDiagnostics(diagnostics); diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 8f8b7c5c34..0c02fe9a87 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -444,8 +444,13 @@ const diagnostics = { unassignable: { severity: "error", messages: { - default: paramMessage`Type '${"value"}' is not assignable to type '${"targetType"}'`, - withDetails: paramMessage`Type '${"sourceType"}' is not assignable to type '${"targetType"}'\n ${"details"}`, + default: paramMessage`Type '${"sourceType"}' is not assignable to type '${"targetType"}'`, + }, + }, + "property-unassignable": { + severity: "error", + messages: { + default: paramMessage`Types of property '${"propName"}' are incompatible`, }, }, "property-required": { diff --git a/packages/compiler/src/core/type-relation-checker.ts b/packages/compiler/src/core/type-relation-checker.ts index e49d898936..773e646eac 100644 --- a/packages/compiler/src/core/type-relation-checker.ts +++ b/packages/compiler/src/core/type-relation-checker.ts @@ -12,7 +12,7 @@ import { getMinValueAsNumeric, getMinValueExclusiveAsNumeric, } from "./intrinsic-type-state.js"; -import { createDiagnostic } from "./messages.js"; +import { CompilerDiagnostics, createDiagnostic } from "./messages.js"; import { numericRanges } from "./numeric-ranges.js"; import { Numeric } from "./numeric.js"; import { Program } from "./program.js"; @@ -21,7 +21,7 @@ import { ArrayModelType, ArrayValue, Diagnostic, - DiagnosticTarget, + DiagnosticReport, Entity, Enum, IndeterminateEntity, @@ -30,6 +30,8 @@ import { ModelIndexer, ModelProperty, Namespace, + Node, + NoTarget, NumericLiteral, Scalar, StringLiteral, @@ -41,12 +43,45 @@ import { Value, } from "./types.js"; +export interface TypeRelation { + isTypeAssignableTo( + source: Entity | IndeterminateEntity, + target: Entity, + diagnosticTarget: Entity | Node + ): [boolean, readonly Diagnostic[]]; + + isValueOfType( + source: Value, + target: Type, + diagnosticTarget: Entity | Node + ): [boolean, readonly Diagnostic[]]; + + isReflectionType(type: Type): type is Model & { name: ReflectionTypeName }; + + areScalarsRelated(source: Scalar, target: Scalar): boolean; +} + enum Related { false = 0, true = 1, maybe = 2, } +interface TypeRelationError { + code: + | "unassignable" + | "property-unassignable" + | "missing-index" + | "property-required" + | "missing-property" + | "unexpected-property"; + message: string; + children: readonly TypeRelationError[]; + target: Entity | Node; + /** If the first error and it has a child show the child error at this target instead */ + skipIfFirst?: boolean; +} + /** * Mapping from the reflection models to Type["kind"] value */ @@ -69,24 +104,6 @@ const _assertReflectionNameToKind: Record = ReflectionName type ReflectionTypeName = keyof typeof ReflectionNameToKind; -export interface TypeRelation { - isTypeAssignableTo( - source: Entity | IndeterminateEntity, - target: Entity, - diagnosticTarget: DiagnosticTarget - ): [boolean, readonly Diagnostic[]]; - - isValueOfType( - source: Value, - target: Type, - diagnosticTarget: DiagnosticTarget - ): [boolean, readonly Diagnostic[]]; - - isReflectionType(type: Type): type is Model & { name: ReflectionTypeName }; - - areScalarsRelated(source: Scalar, target: Scalar): boolean; -} - export function createTypeRelationChecker(program: Program, checker: Checker): TypeRelation { return { isTypeAssignableTo, @@ -104,15 +121,83 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isTypeAssignableTo( source: Entity | IndeterminateEntity, target: Entity, - diagnosticTarget: DiagnosticTarget + diagnosticTarget: Entity | Node ): [boolean, readonly Diagnostic[]] { - const [related, diagnostics] = isTypeAssignableToInternal( + const [related, errors] = isTypeAssignableToInternal( source, target, diagnosticTarget, new MultiKeyMap<[Entity, Entity], Related>() ); - return [related === Related.true, diagnostics]; + return [related === Related.true, convertErrorsToDiagnostics(errors, diagnosticTarget)]; + } + + function isTargetChildOf(target: Entity | Node, base: Entity | Node) { + const errorNode: Node = + "kind" in target && typeof target.kind === "number" ? target : (target as any).node; + const baseNode: Node = + "kind" in base && typeof base.kind === "number" ? base : (base as any).node; + let currentNode: Node | undefined = errorNode; + while (currentNode) { + if (currentNode === baseNode) { + return true; + } + currentNode = currentNode.parent; + } + return false; + } + + function convertErrorsToDiagnostics( + errors: readonly TypeRelationError[], + diagnosticBase: Entity | Node + ): readonly Diagnostic[] { + return errors.flatMap((x) => convertErrorToDiagnostic(x, diagnosticBase)); + } + + function combineErrorMessage(error: TypeRelationError): string { + let message = error.message; + let current = error.children[0]; + let indent = " "; + while (current !== undefined) { + message += current.message + .split("\n") + .map((line) => `\n${indent}${line}`) + .join(""); + indent += " "; + current = current.children[0]; + } + return message; + } + + function flattenErrors( + error: TypeRelationError, + diagnosticBase: Entity | Node + ): TypeRelationError[] { + if (!isTargetChildOf(error.target, diagnosticBase)) { + return [{ ...error, target: diagnosticBase }]; + } + if (error.children.length === 0) { + return [error]; + } + return error.children.flatMap((x) => flattenErrors(x, error.target)); + } + function convertErrorToDiagnostic( + error: TypeRelationError, + diagnosticBase: Entity | Node + ): Diagnostic[] { + const flattened = flattenErrors(error, diagnosticBase); + return flattened.map((error) => { + const messageBase = + error.skipIfFirst && error.children.length > 0 ? error.children[0] : error; + const message = combineErrorMessage(messageBase); + + return { + severity: "error", + code: error.code, + message: message, + target: error.target, + }; + }); } /** @@ -124,23 +209,23 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isValueOfType( source: Value, target: Type, - diagnosticTarget: DiagnosticTarget + diagnosticTarget: Entity | Node ): [boolean, readonly Diagnostic[]] { - const [related, diagnostics] = isValueOfTypeInternal( + const [related, errors] = isValueOfTypeInternal( source, target, diagnosticTarget, new MultiKeyMap<[Entity, Entity], Related>() ); - return [related === Related.true, diagnostics]; + return [related === Related.true, convertErrorsToDiagnostics(errors, diagnosticTarget)]; } function isTypeAssignableToInternal( source: Entity | IndeterminateEntity, target: Entity, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity | IndeterminateEntity, Entity], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { const cached = relationCache.get([source, target]); if (cached !== undefined) { return [cached, []]; @@ -158,9 +243,9 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isTypeAssignableToWorker( source: Entity | IndeterminateEntity, target: Entity, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { // BACKCOMPAT: Allow certain type to be accepted as values if ( "kind" in source && @@ -255,13 +340,13 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T return [ Related.false, [ - createDiagnostic({ + createTypeRelationError({ code: "missing-index", format: { indexType: getTypeName(source.indexer.key), sourceType: getTypeName(target), }, - target: diagnosticTarget, + diagnosticTarget, }), ], ]; @@ -282,7 +367,7 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T return [Related.false, [createUnassignableDiagnostic(source, target, diagnosticTarget)]]; } } else if (target.kind === "Model" && source.kind === "Model") { - return isModelRelatedTo(source, target, diagnosticTarget, relationCache); + return areModelsRelated(source, target, diagnosticTarget, relationCache); } else if ( target.kind === "Model" && isArrayModelType(program, target) && @@ -303,9 +388,9 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isIndeterminateEntityAssignableTo( indeterminate: IndeterminateEntity, target: Type | MixedParameterConstraint, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { const [typeRelated, typeDiagnostics] = isTypeAssignableToInternal( indeterminate.type, target, @@ -335,9 +420,9 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isAssignableToValueType( source: Entity, target: Type, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { if (!isValue(source)) { return [Related.false, [createUnassignableDiagnostic(source, target, diagnosticTarget)]]; } @@ -348,9 +433,9 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isAssignableToMixedParameterConstraint( source: Entity, target: MixedParameterConstraint, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { if ("entityKind" in source && source.entityKind === "MixedParameterConstraint") { if (source.type && target.type) { const [variantAssignable, diagnostics] = isTypeAssignableToInternal( @@ -408,9 +493,9 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isValueOfTypeInternal( source: Value, target: Type, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { return isTypeAssignableToInternal(source.type, target, diagnosticTarget, relationCache); } @@ -563,29 +648,29 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T return true; } - function isModelRelatedTo( + function areModelsRelated( source: Model, target: Model, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { relationCache.set([source, target], Related.maybe); - const diagnostics: Diagnostic[] = []; + const errors: TypeRelationError[] = []; const remainingProperties = new Map(source.properties); for (const prop of walkPropertiesInherited(target)) { const sourceProperty = getProperty(source, prop.name); if (sourceProperty === undefined) { if (!prop.optional) { - diagnostics.push( - createDiagnostic({ + errors.push( + createTypeRelationError({ code: "missing-property", format: { propertyName: prop.name, sourceType: getTypeName(source), targetType: getTypeName(target), }, - target: source, + diagnosticTarget: source, }) ); } @@ -593,25 +678,25 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T remainingProperties.delete(prop.name); if (sourceProperty.optional && !prop.optional) { - diagnostics.push( - createDiagnostic({ + errors.push( + createTypeRelationError({ code: "property-required", format: { propName: prop.name, targetType: getTypeName(target), }, - target: diagnosticTarget, + diagnosticTarget, }) ); } - const [related, propDiagnostics] = isTypeAssignableToInternal( + const [related, propErrors] = isTypeAssignableToInternal( sourceProperty.type, prop.type, diagnosticTarget, relationCache ); if (!related) { - diagnostics.push(...propDiagnostics); + errors.push(...wrapUnassignablePropertyErrors(sourceProperty, propErrors)); } } } @@ -623,7 +708,7 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T diagnosticTarget, relationCache ); - diagnostics.push(...indexerDiagnostics); + errors.push(...indexerDiagnostics); // For anonymous models we don't need an indexer if (source.name !== "" && target.indexer.key.name !== "integer") { @@ -634,27 +719,30 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T relationCache ); if (!related) { - diagnostics.push(...indexDiagnostics); + errors.push(...indexDiagnostics); } } } else if (shouldCheckExcessProperties(source)) { for (const [propName, prop] of remainingProperties) { if (shouldCheckExcessProperty(prop)) { - diagnostics.push( - createDiagnostic({ + errors.push( + createTypeRelationError({ code: "unexpected-property", format: { propertyName: propName, type: getEntityName(target), }, - target: prop, + diagnosticTarget: prop, }) ); } } } - return [diagnostics.length === 0 ? Related.true : Related.false, diagnostics]; + return [ + errors.length === 0 ? Related.true : Related.false, + wrapUnassignableErrors(source, target, errors), + ]; } /** If we should check for excess properties on the given model. */ @@ -678,9 +766,9 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function arePropertiesAssignableToIndexer( properties: Map, indexerConstaint: Type, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Type, Type], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { for (const prop of properties.values()) { const [related, diagnostics] = isTypeAssignableToInternal( prop.type, @@ -700,20 +788,20 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function hasIndexAndIsAssignableTo( source: Model, target: Model & { indexer: ModelIndexer }, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { if (source.indexer === undefined || source.indexer.key !== target.indexer.key) { return [ Related.false, [ - createDiagnostic({ + createTypeRelationError({ code: "missing-index", format: { indexType: getTypeName(target.indexer.key), sourceType: getTypeName(source), }, - target: diagnosticTarget, + diagnosticTarget, }), ], ]; @@ -729,25 +817,21 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isTupleAssignableToArray( source: Tuple, target: ArrayModelType, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { const minItems = getMinItems(program, target); const maxItems = getMaxItems(program, target); if (minItems !== undefined && source.values.length < minItems) { return [ Related.false, [ - createDiagnostic({ - code: "unassignable", - messageId: "withDetails", - format: { - sourceType: getEntityName(source), - targetType: getTypeName(target), - details: `Source has ${source.values.length} element(s) but target requires ${minItems}.`, - }, - target: diagnosticTarget, - }), + createUnassignableDiagnostic( + source, + target, + diagnosticTarget, + `Source has ${source.values.length} element(s) but target requires ${minItems}.` + ), ], ]; } @@ -755,16 +839,12 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T return [ Related.false, [ - createDiagnostic({ - code: "unassignable", - messageId: "withDetails", - format: { - sourceType: getEntityName(source), - targetType: getTypeName(target), - details: `Source has ${source.values.length} element(s) but target only allows ${maxItems}.`, - }, - target: diagnosticTarget, - }), + createUnassignableDiagnostic( + source, + target, + diagnosticTarget, + `Source has ${source.values.length} element(s) but target only allows ${maxItems}.` + ), ], ]; } @@ -785,23 +865,19 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isTupleAssignableToTuple( source: Tuple | ArrayValue, target: Tuple, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, readonly Diagnostic[]] { + ): [Related, readonly TypeRelationError[]] { if (source.values.length !== target.values.length) { return [ Related.false, [ - createDiagnostic({ - code: "unassignable", - messageId: "withDetails", - format: { - sourceType: getEntityName(source), - targetType: getTypeName(target), - details: `Source has ${source.values.length} element(s) but target requires ${target.values.length}.`, - }, - target: diagnosticTarget, - }), + createUnassignableDiagnostic( + source, + target, + diagnosticTarget, + `Source has ${source.values.length} element(s) but target requires ${target.values.length}.` + ), ], ]; } @@ -823,9 +899,9 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isAssignableToUnion( source: Type, target: Union, - diagnosticTarget: DiagnosticTarget, + diagnosticTarget: Entity | Node, relationCache: MultiKeyMap<[Entity, Entity], Related> - ): [Related, Diagnostic[]] { + ): [Related, TypeRelationError[]] { if (source.kind === "UnionVariant" && source.union === target) { return [Related.true, []]; } @@ -846,8 +922,8 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isAssignableToEnum( source: Type, target: Enum, - diagnosticTarget: DiagnosticTarget - ): [Related, Diagnostic[]] { + diagnosticTarget: Entity | Node + ): [Related, TypeRelationError[]] { switch (source.kind) { case "Enum": if (source === target) { @@ -866,17 +942,6 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T } } - function createUnassignableDiagnostic( - source: Entity, - target: Entity, - diagnosticTarget: DiagnosticTarget - ) { - return createDiagnostic({ - code: "unassignable", - format: { targetType: getEntityName(target), value: getEntityName(source) }, - target: diagnosticTarget, - }); - } function isTypeSpecNamespace( namespace: Namespace ): namespace is Namespace & { name: "TypeSpec"; namespace: Namespace } { @@ -887,3 +952,76 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T ); } } + +// #region Helpers +interface TypeRelationeErrorInit { + code: C; + diagnosticTarget: Entity | Node; + format: DiagnosticReport["format"]; + details?: string; + skipIfFirst?: boolean; +} + +function wrapUnassignableErrors( + source: Entity, + target: Entity, + errors: readonly TypeRelationError[] +): readonly TypeRelationError[] { + const error = createUnassignableDiagnostic(source, target, source); + error.children = errors; + return [error]; +} +function wrapUnassignablePropertyErrors( + source: ModelProperty, + errors: readonly TypeRelationError[] +): readonly TypeRelationError[] { + const error = createTypeRelationError({ + code: "property-unassignable", + diagnosticTarget: source, + format: { + propName: source.name, + }, + skipIfFirst: true, + }); + error.children = errors; + return [error]; +} +function createTypeRelationError({ + code, + format, + details, + diagnosticTarget, + skipIfFirst, +}: TypeRelationeErrorInit): TypeRelationError { + const diag = createDiagnostic({ + code: code as any, + format: format, + target: NoTarget, + }); + + return { + code: code, + message: details ? `${diag.message}\n ${details}` : diag.message, + target: diagnosticTarget, + skipIfFirst, + children: [], + }; +} + +function createUnassignableDiagnostic( + source: Entity, + target: Entity, + diagnosticTarget: Entity | Node, + details?: string +): TypeRelationError { + return createTypeRelationError({ + code: "unassignable", + format: { + sourceType: getEntityName(source), + targetType: getEntityName(target), + }, + diagnosticTarget, + details, + }); +} +// #endregion diff --git a/packages/compiler/src/lib/service.ts b/packages/compiler/src/lib/service.ts index abca2d6009..299d20f658 100644 --- a/packages/compiler/src/lib/service.ts +++ b/packages/compiler/src/lib/service.ts @@ -89,7 +89,7 @@ export const $service: ServiceDecorator = ( } else { reportDiagnostic(context.program, { code: "unassignable", - format: { value: getTypeName(title), targetType: "String" }, + format: { sourceType: getTypeName(title), targetType: "String" }, target: context.getArgumentTarget(0)!, }); } @@ -107,7 +107,7 @@ export const $service: ServiceDecorator = ( } else { reportDiagnostic(context.program, { code: "unassignable", - format: { value: getTypeName(version), targetType: "String" }, + format: { sourceType: getTypeName(version), targetType: "String" }, target: context.getArgumentTarget(0)!, }); } diff --git a/packages/compiler/test/checker/relation.test.ts b/packages/compiler/test/checker/relation.test.ts index 36cf0c2284..b62b3ff03a 100644 --- a/packages/compiler/test/checker/relation.test.ts +++ b/packages/compiler/test/checker/relation.test.ts @@ -16,6 +16,7 @@ import { expectDiagnosticEmpty, expectDiagnostics, extractCursor, + extractSquiggles, } from "../../src/testing/index.js"; interface RelatedTypeOptions { @@ -24,14 +25,14 @@ interface RelatedTypeOptions { commonCode?: string; } -describe("compiler: checker: type relations", () => { - let runner: BasicTestRunner; - let host: TestHost; - beforeEach(async () => { - host = await createTestHost(); - runner = createTestWrapper(host); - }); +let runner: BasicTestRunner; +let host: TestHost; +beforeEach(async () => { + host = await createTestHost(); + runner = createTestWrapper(host); +}); +describe("compiler: checker: type relations", () => { async function checkTypeAssignable({ source, target, commonCode }: RelatedTypeOptions): Promise<{ related: boolean; diagnostics: readonly Diagnostic[]; @@ -841,7 +842,10 @@ describe("compiler: checker: type relations", () => { { source: `Record`, target: "Record" }, { code: "unassignable", - message: "Type 'int32' is not assignable to type 'string'", + message: [ + `Type 'Record' is not assignable to type 'Record'`, + " Type 'int32' is not assignable to type 'string'", + ].join("\n"), } ); }); @@ -963,8 +967,11 @@ describe("compiler: checker: type relations", () => { }); ok(!related); expectDiagnostics(diagnostics, { - code: "missing-property", - message: "Property 'b' is missing on type 'A' but required in 'B'", + code: "unassignable", + message: [ + `Type 'A' is not assignable to type 'B'`, + " Property 'b' is missing on type 'A' but required in 'B'", + ].join("\n"), }); }); }); @@ -1716,3 +1723,101 @@ describe("compiler: checker: type relations", () => { }); }); }); + +describe("relation error target and messages", () => { + async function expectRelationDiagnostics(code: string, expected: DiagnosticMatch) { + const { pos, end, source } = extractSquiggles(code, "┆"); + const diagnostics = await runner.diagnose(source); + expectDiagnostics(diagnostics, { + pos, + end, + ...expected, + }); + } + + it("report missing property at assignment right on the object literal", async () => { + await expectRelationDiagnostics(`const a: {a: string} = ┆#{}┆;`, { + code: "missing-property", + message: "Property 'a' is missing on type '{}' but required in '{ a: string }'", + }); + }); + + it("report missing property at assignment right on the object literal (nested)", async () => { + await expectRelationDiagnostics(`const a: {prop: {a: string}} = #{prop: ┆#{}┆};`, { + code: "missing-property", + message: "Property 'a' is missing on type '{}' but required in '{ a: string }'", + }); + }); + + it("report extra property at assignment right on the property literal", async () => { + await expectRelationDiagnostics(`const a: {} = #{┆a: "abc"┆};`, { + code: "unexpected-property", + message: + "Object value may only specify known properties, and 'a' does not exist in type '{}'.", + }); + }); + + it("report multiple extra property at assignment right on the property literal", async () => { + const { source: sourceTmp, ...pos1 } = extractSquiggles( + `const a: {} = #{┆a: "abc"┆, ┆b: "abc"┆};`, + "┆" + ); + const { source, ...pos2 } = extractSquiggles(sourceTmp, "┆"); + const diagnostics = await runner.diagnose(source); + expectDiagnostics(diagnostics, [ + { + code: "unexpected-property", + message: + "Object value may only specify known properties, and 'a' does not exist in type '{}'.", + ...pos1, + }, + { + code: "unexpected-property", + message: + "Object value may only specify known properties, and 'b' does not exist in type '{}'.", + ...pos2, + }, + ]); + }); + + it("report extra property at assignment right on the property literal (nested)", async () => { + await expectRelationDiagnostics(`const a: {prop: {}} = #{ prop: #{┆a: "abc"┆}};`, { + code: "unexpected-property", + message: + "Object value may only specify known properties, and 'a' does not exist in type '{}'.", + }); + }); + + it("report with full stack if originate from another declaration", async () => { + await expectRelationDiagnostics( + ` + const b = #{ prop: #{a: "abc"}}; + const ┆a┆: {prop: {}} = b;`, + { + code: "unassignable", + message: [ + `Type '{ prop: { a: "abc" } }' is not assignable to type '{ prop: {} }'`, + ` Types of property 'prop' are incompatible`, + ` Type '{ a: "abc" }' is not assignable to type '{}'`, + ` Object value may only specify known properties, and 'a' does not exist in type '{}'.`, + ].join("\n"), + } + ); + }); + + it("show up error in the further node without leaving the base", async () => { + await expectRelationDiagnostics( + ` + const b = #{a: "abc"}; + const a: { prop: { a: int32 } } = #{ ┆prop: b┆ };`, + { + code: "unassignable", + message: [ + `Type '{ a: "abc" }' is not assignable to type '{ a: int32 }'`, + ` Types of property 'a' are incompatible`, + ` Type '"abc"' is not assignable to type 'int32'`, + ].join("\n"), + } + ); + }); +});