Skip to content

Commit

Permalink
(feat) add experimal support for generics (#1053)
Browse files Browse the repository at this point in the history
By defining a type which uses the `$$Generic` type, it's now possible to definine generics. Example:

// definition
type T = $$Generic;
type K = $$Generic<keyof T>; // interpreted as "K extends keyof T"
// usage
export let t: T;
export let k: K;

The content of a $$Generic type is moved to the render function: Its identifier is the generic name, a possibly type argument is the extends clause.
During that, the seemingly unnecessary typing of props/slots/events as being optional on the render function is removed because it makes TS throw wrong errors in strict mode

#442
#273
sveltejs/rfcs#38
  • Loading branch information
dummdidumm authored Jun 14, 2021
1 parent d9b6ebf commit 1992d63
Show file tree
Hide file tree
Showing 18 changed files with 862 additions and 201 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {
} from 'vscode-languageserver';
import { Document, mapRangeToOriginal } from '../../../lib/documents';
import { SemanticTokensProvider } from '../../interfaces';
import { SnapshotFragment } from '../DocumentSnapshot';
import { SvelteSnapshotFragment } from '../DocumentSnapshot';
import { LSAndTSDocResolver } from '../LSAndTSDocResolver';
import { convertToTextSpan } from '../utils';
import { isInGeneratedCode } from './utils';

const CONTENT_LENGTH_LIMIT = 50000;

Expand Down Expand Up @@ -104,10 +105,14 @@ export class SemanticTokensProviderImpl implements SemanticTokensProvider {

private mapToOrigin(
document: Document,
fragment: SnapshotFragment,
fragment: SvelteSnapshotFragment,
generatedOffset: number,
generatedLength: number
): [line: number, character: number, length: number] | undefined {
if (isInGeneratedCode(fragment.text, generatedOffset, generatedOffset + generatedLength)) {
return;
}

const range = {
start: fragment.positionAt(generatedOffset),
end: fragment.positionAt(generatedOffset + generatedLength)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1215,4 +1215,82 @@ describe('DiagnosticsProvider', () => {
const diagnostics = await plugin.getDiagnostics(document);
assertPropsDiagnostics(diagnostics, 'js');
});

it('checks generics correctly', async () => {
const { plugin, document } = setup('diagnostics-generics.svelte');
const diagnostics = await plugin.getDiagnostics(document);
assert.deepStrictEqual(diagnostics, [
{
code: 2322,
message:
'Type \'"asd"\' is not assignable to type \'number | unique symbol | "toString" | "charAt" | "charCodeAt" | "concat" | "indexOf" | "lastIndexOf" | "localeCompare" | "match" | "replace" | "search" | "slice" | "split" | "substring" | ... 34 more ... | "replaceAll"\'.',
range: {
start: {
character: 25,
line: 10
},
end: {
character: 26,
line: 10
}
},
severity: 1,
source: 'ts',
tags: []
},
{
code: 2322,
message: "Type 'string' is not assignable to type 'boolean'.",
range: {
start: {
character: 35,
line: 10
},
end: {
character: 36,
line: 10
}
},
severity: 1,
source: 'ts',
tags: []
},
{
code: 2367,
message:
"This condition will always return 'false' since the types 'string' and 'boolean' have no overlap.",
range: {
start: {
character: 3,
line: 11
},
end: {
character: 13,
line: 11
}
},
severity: 1,
source: 'ts',
tags: []
},
{
code: 2367,
message:
"This condition will always return 'false' since the types 'string' and 'boolean' have no overlap.",
range: {
end: {
character: 72,
line: 10
},
start: {
character: 55,
line: 10
}
},
severity: 1,
source: 'ts',
tags: []
}
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import Generics from './generics.svelte';
</script>

<!-- valid -->
<Generics a={['a', 'b']} b={'anchor'} c={false} on:b={(e) => e.detail === 'str'} let:a>
{a === 'str'}
</Generics>

<!-- invalid -->
<Generics a={['a', 'b']} b={'asd'} c={''} on:b={(e) => e.detail === true} let:a>
{a === true}
</Generics>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
type A = $$Generic;
type B = $$Generic<keyof A>;
type C = $$Generic<boolean>;
export let a: A[];
export let b: B;
export let c: C;
const dispatch = createEventDispatcher<{ b: A }>();
dispatch('b', a[0]);
</script>

<slot a={a[0]} />
206 changes: 206 additions & 0 deletions packages/svelte2tsx/src/svelte2tsx/addComponentExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { pascalCase } from 'pascal-case';
import path from 'path';
import { createClassGetters } from './nodes/exportgetters';
import { createClassAccessors } from './nodes/exportaccessors';
import MagicString from 'magic-string';
import { ExportedNames } from './nodes/ExportedNames';
import { ComponentDocumentation } from './nodes/ComponentDocumentation';
import { Generics } from './nodes/Generics';

export interface AddComponentExportPara {
str: MagicString;
uses$$propsOr$$restProps: boolean;
/**
* If true, not fallback to `any`
* -> all unknown events will throw a type error
* */
strictEvents: boolean;
isTsFile: boolean;
getters: Set<string>;
usesAccessors: boolean;
exportedNames: ExportedNames;
fileName?: string;
componentDocumentation: ComponentDocumentation;
mode: 'dts' | 'tsx';
generics: Generics;
}

/**
* A component class name suffix is necessary to prevent class name clashes
* like reported in https:/sveltejs/language-tools/issues/294
*/
export const COMPONENT_SUFFIX = '__SvelteComponent_';

export function addComponentExport(params: AddComponentExportPara) {
if (params.generics.has()) {
addGenericsComponentExport(params);
} else {
addSimpleComponentExport(params);
}
}

function addGenericsComponentExport({
strictEvents,
isTsFile,
uses$$propsOr$$restProps,
exportedNames,
componentDocumentation,
fileName,
mode,
getters,
usesAccessors,
str,
generics
}: AddComponentExportPara) {
const genericsDef = generics.toDefinitionString();
const genericsRef = generics.toReferencesString();

const doc = componentDocumentation.getFormatted();
const className = fileName && classNameFromFilename(fileName, mode !== 'dts');

function returnType(forPart: string) {
return `ReturnType<__sveltets_Render${genericsRef}['${forPart}']>`;
}

let statement = `
class __sveltets_Render${genericsDef} {
props() {
return ${props(
isTsFile,
uses$$propsOr$$restProps,
exportedNames,
`render${genericsRef}()`
)}.props;
}
events() {
return ${events(strictEvents, `render${genericsRef}()`)}.events;
}
slots() {
return render${genericsRef}().slots;
}
}
`;

if (mode === 'dts') {
statement +=
`export type ${className}Props${genericsDef} = ${returnType('props')};\n` +
`export type ${className}Events${genericsDef} = ${returnType('events')};\n` +
`export type ${className}Slots${genericsDef} = ${returnType('slots')};\n` +
`\n${doc}export default class${
className ? ` ${className}` : ''
}${genericsDef} extends SvelteComponentTyped<${className}Props${genericsRef}, ${className}Events${genericsRef}, ${className}Slots${genericsRef}> {` + // eslint-disable-line max-len
createClassGetters(getters) +
(usesAccessors ? createClassAccessors(getters, exportedNames) : '') +
'\n}';
} else {
statement +=
`\n\n${doc}export default class${
className ? ` ${className}` : ''
}${genericsDef} extends Svelte2TsxComponent<${returnType('props')}, ${returnType(
'events'
)}, ${returnType('slots')}> {` +
createClassGetters(getters) +
(usesAccessors ? createClassAccessors(getters, exportedNames) : '') +
'\n}';
}

str.append(statement);
}

function addSimpleComponentExport({
strictEvents,
isTsFile,
uses$$propsOr$$restProps,
exportedNames,
componentDocumentation,
fileName,
mode,
getters,
usesAccessors,
str
}: AddComponentExportPara) {
const propDef = props(
isTsFile,
uses$$propsOr$$restProps,
exportedNames,
events(strictEvents, 'render()')
);

const doc = componentDocumentation.getFormatted();
const className = fileName && classNameFromFilename(fileName, mode !== 'dts');

let statement: string;
if (mode === 'dts') {
statement =
`\nconst __propDef = ${propDef};\n` +
`export type ${className}Props = typeof __propDef.props;\n` +
`export type ${className}Events = typeof __propDef.events;\n` +
`export type ${className}Slots = typeof __propDef.slots;\n` +
`\n${doc}export default class${
className ? ` ${className}` : ''
} extends SvelteComponentTyped<${className}Props, ${className}Events, ${className}Slots> {` + // eslint-disable-line max-len
createClassGetters(getters) +
(usesAccessors ? createClassAccessors(getters, exportedNames) : '') +
'\n}';
} else {
statement =
`\n\n${doc}export default class${
className ? ` ${className}` : ''
} extends createSvelte2TsxComponent(${propDef}) {` +
createClassGetters(getters) +
(usesAccessors ? createClassAccessors(getters, exportedNames) : '') +
'\n}';
}

str.append(statement);
}

function events(strictEvents: boolean, renderStr: string) {
return strictEvents ? renderStr : `__sveltets_with_any_event(${renderStr})`;
}

function props(
isTsFile: boolean,
uses$$propsOr$$restProps: boolean,
exportedNames: ExportedNames,
renderStr: string
) {
if (isTsFile) {
return uses$$propsOr$$restProps ? `__sveltets_with_any(${renderStr})` : renderStr;
} else {
const optionalProps = exportedNames.createOptionalPropsArray();
const partial = `__sveltets_partial${uses$$propsOr$$restProps ? '_with_any' : ''}`;
return optionalProps.length > 0
? `${partial}([${optionalProps.join(',')}], ${renderStr})`
: `${partial}(${renderStr})`;
}
}

/**
* Returns a Svelte-compatible component name from a filename. Svelte
* components must use capitalized tags, so we try to transform the filename.
*
* https://svelte.dev/docs#Tags
*/
function classNameFromFilename(filename: string, appendSuffix: boolean): string | undefined {
try {
const withoutExtensions = path.parse(filename).name?.split('.')[0];
const withoutInvalidCharacters = withoutExtensions
.split('')
// Although "-" is invalid, we leave it in, pascal-case-handling will throw it out later
.filter((char) => /[A-Za-z$_\d-]/.test(char))
.join('');
const firstValidCharIdx = withoutInvalidCharacters
.split('')
// Although _ and $ are valid first characters for classes, they are invalid first characters
// for tag names. For a better import autocompletion experience, we therefore throw them out.
.findIndex((char) => /[A-Za-z]/.test(char));
const withoutLeadingInvalidCharacters = withoutInvalidCharacters.substr(firstValidCharIdx);
const inPascalCase = pascalCase(withoutLeadingInvalidCharacters);
const finalName = firstValidCharIdx === -1 ? `A${inPascalCase}` : inPascalCase;
return `${finalName}${appendSuffix ? COMPONENT_SUFFIX : ''}`;
} catch (error) {
console.warn(`Failed to create a name for the component class from filename ${filename}`);
return undefined;
}
}
Loading

2 comments on commit 1992d63

@AlexGalays
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you plan on releasing this soon? 🙏

@dummdidumm
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a week or so

Please sign in to comment.