diff --git a/.changeset/quick-coins-joke.md b/.changeset/quick-coins-joke.md new file mode 100644 index 00000000..e542efc9 --- /dev/null +++ b/.changeset/quick-coins-joke.md @@ -0,0 +1,5 @@ +--- +"@jackolope/lit-analyzer": patch +--- + +fix: Take the presence of converter functions into account for attributes with no-complex-attribute-binding and no-incompatible-type-binding rules diff --git a/packages/lit-analyzer/src/lib/rules/no-complex-attribute-binding.ts b/packages/lit-analyzer/src/lib/rules/no-complex-attribute-binding.ts index 84eca74a..c9195906 100644 --- a/packages/lit-analyzer/src/lib/rules/no-complex-attribute-binding.ts +++ b/packages/lit-analyzer/src/lib/rules/no-complex-attribute-binding.ts @@ -28,6 +28,9 @@ const rule: RuleModule = { // Don't validate directives in this rule, because they are assignable even though they are complex types (functions). if (isLitDirective(typeB)) return; + const htmlAttrTarget = context.htmlStore.getHtmlAttrTarget(htmlAttr); + const hasConverter = htmlAttrTarget?.declaration?.meta?.hasConverter; + // Only primitive types should be allowed as "typeB" if (!isAssignableToPrimitiveType(typeB)) { if (isAssignableBindingUnderSecuritySystem(htmlAttr, { typeA, typeB }, context) !== undefined) { @@ -56,8 +59,8 @@ const rule: RuleModule = { }); } - // Only primitive types should be allowed as "typeA" - else if (!isAssignableToPrimitiveType(typeA)) { + // Only primitive types without a custom converter should be allowed as "typeA" + else if (!hasConverter && !isAssignableToPrimitiveType(typeA)) { const message = `You are assigning the primitive '${typeToString(typeB)}' to a non-primitive type '${typeToString(typeA)}'.`; const newModifier = "."; diff --git a/packages/lit-analyzer/src/lib/rules/util/type/is-assignable-in-attribute-binding.ts b/packages/lit-analyzer/src/lib/rules/util/type/is-assignable-in-attribute-binding.ts index d1d515e8..896c2f6e 100644 --- a/packages/lit-analyzer/src/lib/rules/util/type/is-assignable-in-attribute-binding.ts +++ b/packages/lit-analyzer/src/lib/rules/util/type/is-assignable-in-attribute-binding.ts @@ -9,6 +9,7 @@ import { isPrimitiveArrayType } from "../../../analyze/util/type-util.js"; import { isLitDirective } from "../directive/is-lit-directive.js"; import { isAssignableBindingUnderSecuritySystem } from "./is-assignable-binding-under-security-system.js"; import { isAssignableToType } from "./is-assignable-to-type.js"; +import { HtmlNodeAttrKind } from "../../../analyze/types/html-node/html-node-attr-types.js"; export function isAssignableInAttributeBinding( htmlAttr: HtmlNodeAttr, @@ -18,6 +19,16 @@ export function isAssignableInAttributeBinding( const { assignment } = htmlAttr; if (assignment == null) return undefined; + // If the attribute has a custom converter, then do not check it + if (htmlAttr.kind === HtmlNodeAttrKind.ATTRIBUTE) { + const htmlAttrTarget = context.htmlStore.getHtmlAttrTarget(htmlAttr); + const hasConverter = htmlAttrTarget?.declaration?.meta?.hasConverter; + + if (hasConverter) { + return undefined; + } + } + if (assignment.kind === HtmlNodeAttrAssignmentKind.BOOLEAN) { if (!isAssignableToType({ typeA, typeB }, context)) { context.report({ diff --git a/packages/lit-analyzer/src/test/helpers/generate-test-file.ts b/packages/lit-analyzer/src/test/helpers/generate-test-file.ts index 104631d1..fb6aaa89 100644 --- a/packages/lit-analyzer/src/test/helpers/generate-test-file.ts +++ b/packages/lit-analyzer/src/test/helpers/generate-test-file.ts @@ -1,6 +1,28 @@ import type { TestFile } from "./compile-files.js"; -export function makeElement({ properties, slots }: { properties?: string[]; slots?: string[] }): TestFile { +/** + * Allows you to configure and test options that you would provide to Lit's @property decorator by setting `fullPropertyDeclaration` to true. + * @link https://lit.dev/docs/api/ReactiveElement/#PropertyDeclaration + * Some of these options are used in the analyzer, and are set in the `parse-lit-property-configuration` file in the web-component-analyzer-package. + * They are available on the `.meta` field. + */ +export function makeElement({ + properties, + slots, + fullPropertyDeclaration +}: { + properties?: string[]; + slots?: string[]; + fullPropertyDeclaration?: boolean; +}): TestFile { + let propertiesString: string | undefined; + + if (fullPropertyDeclaration) { + propertiesString = properties?.join("\n"); + } else { + propertiesString = properties?.map(prop => `@property() ${prop}`).join("\n"); + } + return { fileName: "my-element.ts", text: ` @@ -8,7 +30,7 @@ export function makeElement({ properties, slots }: { properties?: string[]; slot ${(slots || []).map(slot => ` * @slot ${slot}`)} */ class MyElement extends HTMLElement { - ${(properties || []).map(prop => `@property() ${prop}`).join("\n")} + ${propertiesString} }; customElements.define("my-element", MyElement); ` diff --git a/packages/lit-analyzer/src/test/rules/no-complex-attribute-binding.ts b/packages/lit-analyzer/src/test/rules/no-complex-attribute-binding.ts index 90dce5ef..42e0135f 100644 --- a/packages/lit-analyzer/src/test/rules/no-complex-attribute-binding.ts +++ b/packages/lit-analyzer/src/test/rules/no-complex-attribute-binding.ts @@ -37,3 +37,27 @@ tsTest("Ignore element expressions", t => { const { diagnostics } = getDiagnostics("html``", { rules: { "no-incompatible-type-binding": false } }); hasNoDiagnostics(t, diagnostics); }); + +tsTest("Complex types are assignable to attributes using converters", t => { + const { diagnostics } = getDiagnostics( + [ + makeElement({ + properties: [ + `@property({ converter: { + fromAttribute(str) { return str.split(','); }, + toAttribute(arr) { return arr.join(','); } + }}) + complex: string[];` + ], + fullPropertyDeclaration: true + }), + 'html``' + ], + { + rules: { + "no-incompatible-type-binding": "off" + } + } + ); + hasNoDiagnostics(t, diagnostics); +}); diff --git a/packages/lit-analyzer/src/test/rules/no-incompatible-type-binding.ts b/packages/lit-analyzer/src/test/rules/no-incompatible-type-binding.ts index 30789bde..80b59f5e 100644 --- a/packages/lit-analyzer/src/test/rules/no-incompatible-type-binding.ts +++ b/packages/lit-analyzer/src/test/rules/no-incompatible-type-binding.ts @@ -294,6 +294,23 @@ tsTest("Attribute binding: the target attribute is correctly type checked when g hasNoDiagnostics(t, diagnostics); }); +tsTest("Strings are assignable to types with converters", t => { + const { diagnostics } = getDiagnostics([ + makeElement({ + properties: [ + `@property({ converter: { + fromAttribute(str) { return str.split(','); }, + toAttribute(arr) { return arr.join(','); } + }}) + complex: string[];` + ], + fullPropertyDeclaration: true + }), + 'html``' + ]); + hasNoDiagnostics(t, diagnostics); +}); + tsTest("Attribute binding: any symbols are ignored on type checking", t => { const { diagnostics } = getDiagnostics(` declare const value: boolean | unique symbol;