Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ Glint's directives work as before:
- `{{! @glint-ignore }}` suppresses the diagnostics on the next line.
- `{{! @glint-nocheck }}` suppresses the diagnostics in the whole template.

### JSDoc `@extends` in `.gjs` files

TypeScript 7 ignores `/** @extends {Component<Signature>} */` when the class extends a call
expression such as `Component.extend(Evented)`
([microsoft/TypeScript#64058](https://github.com/microsoft/TypeScript/issues/64058)). The mapper
wraps such a heritage in a JSDoc type cast in the transformed text, so the signature applies. The
cast drops the static side of the heritage on the subclass. The workaround goes away once the fix
ships.

### Declaration files

A sibling declaration file wins over transforming the module, matching Glint: `counter.gjs` is
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"dependencies": {
"@glint/ember-tsc": "~1.11.0",
"oxc-parser": "^0.130.0",
"vscode-jsonrpc": "^9.0.0"
},
"devDependencies": {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 22 additions & 2 deletions src/requests/transform.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { rewriteModuleStandalone } from '@glint/ember-tsc/transform/standalone';

import { SpanMapKind } from '../constants.js';
import { buildDiagnosticDirectives } from '../util/directives.js';
import { applyInsertions, castCallHeritages } from '../util/heritage.js';
import { buildMappings } from '../util/mappings.js';
import { projects } from '../util/projects.js';
import { prependReferences } from '../util/references.js';
Expand Down Expand Up @@ -103,7 +104,7 @@ export function transform(params) {
passthrough.mappings = [[0, content.length, 0, content.length, SpanMapKind.Verbatim]];
}

return prependReferences(passthrough, project.referencePrefix);
return prependReferences(withHeritageCasts(passthrough, fileName), project.referencePrefix);
}

if (transformedModule.errors.some((error) => error.isContentTagError)) {
Expand Down Expand Up @@ -166,5 +167,24 @@ export function transform(params) {
result.diagnostics = diagnostics;
}

return prependReferences(result, project.referencePrefix);
return prependReferences(withHeritageCasts(result, fileName), project.referencePrefix);
}

/**
* Work around microsoft/TypeScript#64058 for JavaScript output. See
* `castCallHeritages`.
*
* @param {TransformResult} result
* The transform result, mutated in place.
* @param {string} fileName
* The original file name.
* @returns {TransformResult}
* The same result.
*/
function withHeritageCasts(result, fileName) {
if (result.extension !== '.js') {
return result;
}

return applyInsertions(result, castCallHeritages(result.text, fileName));
}
282 changes: 282 additions & 0 deletions src/util/heritage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
/**
* @import { SpanMapping, TransformResult } from '../protocol.js'
*/

import { parseSync } from 'oxc-parser';

/**
* A piece of text to insert into the transformed text.
*
* @typedef {object} Insertion
* @property {number} offset
* The virtual offset the text goes in front of, before any insertion.
* @property {string} text
* The text to insert.
*/

/**
* @typedef {{ type: string, start: number, end: number } & Record<string, unknown>} Node
*/

/**
* Extract the type from a JSDoc `@extends {Type}` or `@augments {Type}` tag.
*
* @param {string} comment
* The comment text, without the delimiters.
* @returns {string | undefined}
* The type, or `undefined` when the comment has no such tag.
*/
function extendsType(comment) {
const match = /@(?:extends|augments)\s*\{/.exec(comment);
if (!match) {
return undefined;
}

// Signatures nest braces (`Component<{ Args: {...} }>`), so match them.
let depth = 1;
const start = match.index + match[0].length;
for (let index = start; index < comment.length; index += 1) {
if (comment[index] === '{') {
depth += 1;
} else if (comment[index] === '}') {
depth -= 1;
if (depth === 0) {
return comment.slice(start, index).trim();
}
}
}

return undefined;
}

/**
* Find the insertions that give TypeScript 7 the JSDoc `@extends` signature
* of every class whose heritage is a call expression.
*
* TypeScript 7 attaches a JSDoc `extends` tag only to an identifier heritage.
* Over `Base.extend(Mixin)` the tag is ignored, so the class has no signature
* and every invocation reports `TS2554: Expected 0 arguments`
* (microsoft/TypeScript#64058). TypeScript 5.9 and 6 honor the tag. Both
* honor a JSDoc type cast on the heritage expression, so the transform wraps
* the call in one: `(Base.extend(Mixin))` preceded by a `type` tag of
* `new (...args: any[]) => Base<Sig>`.
*
* The cast loses the static side of the heritage on the subclass. Remove this
* once the upstream fix ships in the nightly the README requires.
*
* @param {string} text
* Transformed JavaScript text.
* @param {string} fileName
* The file name, for the parser's dialect detection.
* @returns {Insertion[]}
* The insertions, in ascending offset order. Empty when the text has no
* such class or does not parse.
*/
export function castCallHeritages(text, fileName) {
const { program, comments, errors } = parseSync(fileName, text);
if (errors.length > 0) {
return [];
}

/** @type {Insertion[]} */
const insertions = [];

/**
* The JSDoc block that documents a node: the last block comment before it
* with only whitespace in between.
*
* @param {number} start
* The node's start offset.
* @returns {string | undefined}
* The comment text, if any.
*/
function leadingJsDoc(start) {
for (const comment of comments) {
if (
comment.type === 'Block' &&
comment.value.startsWith('*') &&
comment.end <= start &&
text.slice(comment.end, start).trim() === ''
) {
return comment.value;
}
}

return undefined;
}

/**
* @param {Node} node
* A class node.
* @param {number} documentedStart
* Where the class's JSDoc attaches: the class itself, or the export
* declaration that wraps it.
*/
function visitClass(node, documentedStart) {
const superClass = /** @type {Node | null} */ (node['superClass']);
if (!superClass || superClass.type !== 'CallExpression') {
return;
}

const comment = leadingJsDoc(documentedStart) ?? leadingJsDoc(node.start);
const type = comment === undefined ? undefined : extendsType(comment);
if (type === undefined) {
return;
}

insertions.push({
offset: superClass.start,
text: `/** @type {new (...args: any[]) => ${type}} */ (`,
});
insertions.push({ offset: superClass.end, text: ')' });
}

/**
* @param {unknown} value
* An AST node, a list of nodes, or a scalar.
* @param {Node | undefined} parent
* The enclosing node.
*/
function walk(value, parent) {
if (Array.isArray(value)) {
for (const item of value) {
walk(item, parent);
}

return;
}

if (value === null || typeof value !== 'object' || !('type' in value)) {
return;
}

const node = /** @type {Node} */ (value);
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
const wrapped =
parent &&
(parent.type === 'ExportDefaultDeclaration' || parent.type === 'ExportNamedDeclaration');
visitClass(node, wrapped ? parent.start : node.start);
}

for (const [key, child] of Object.entries(node)) {
if (key !== 'type' && key !== 'start' && key !== 'end') {
walk(child, node);
}
}
}

walk(program.body, undefined);
insertions.sort((a, b) => a.offset - b.offset);
return insertions;
}

/**
* @param {number} virtualStart
* @param {number} virtualLength
* @param {number} originalStart
* @param {number} originalLength
* @param {SpanMapping} like
* The mapping whose kind and features to copy.
* @returns {SpanMapping}
* A mapping with the given ranges.
*/
function span(virtualStart, virtualLength, originalStart, originalLength, like) {
const [, , , , kind, features] = like;
return features === undefined
? [virtualStart, virtualLength, originalStart, originalLength, kind]
: [virtualStart, virtualLength, originalStart, originalLength, kind, features];
}

/**
* Apply insertions to a transform result, shifting every mapping and
* directive past each insertion point.
*
* A mapping that spans an insertion point is split around the inserted text,
* so the text stays unmapped: it has no counterpart in the original file.
*
* @param {TransformResult} result
* The transform result, mutated in place.
* @param {Insertion[]} insertions
* The insertions, in ascending offset order, with offsets in the text as it
* was before any of them.
* @returns {TransformResult}
* The same result, with the insertions applied.
*/
export function applyInsertions(result, insertions) {
if (insertions.length === 0) {
return result;
}

/** @type {string[]} */
const parts = [];
let cursor = 0;
for (const insertion of insertions) {
parts.push(result.text.slice(cursor, insertion.offset), insertion.text);
cursor = insertion.offset;
}

parts.push(result.text.slice(cursor));
result.text = parts.join('');

/**
* @param {number} offset
* An offset in the text before the insertions.
* @returns {number}
* The same position in the text after them. A position equal to an
* insertion point lands after the inserted text.
*/
function shift(offset) {
let shifted = offset;
for (const insertion of insertions) {
if (insertion.offset <= offset) {
shifted += insertion.text.length;
}
}

return shifted;
}

/** @type {TransformResult['mappings']} */
const mappings = [];
for (const mapping of result.mappings ?? []) {
let [virtualStart, virtualLength, originalStart, originalLength] = mapping;
const virtualEnd = virtualStart + virtualLength;
for (const insertion of insertions) {
if (insertion.offset <= virtualStart || insertion.offset >= virtualEnd) {
continue;
}

// Split around the insertion. A Verbatim mapping splits its original
// range at the same distance; an Atom keeps its original range whole.
const headLength = insertion.offset - virtualStart;
const verbatim = mapping[4] === 0;
mappings.push(
span(
shift(virtualStart),
headLength,
originalStart,
verbatim ? headLength : originalLength,
mapping,
),
);
virtualStart = insertion.offset;
virtualLength -= headLength;
if (verbatim) {
originalStart += headLength;
originalLength -= headLength;
}
}

mappings.push(span(shift(virtualStart), virtualLength, originalStart, originalLength, mapping));
}

result.mappings = mappings;

for (const directive of result.diagnosticDirectives?.directives ?? []) {
const start = directive[2];
directive[2] = shift(start);
directive[3] = shift(directive[3]);
}

return result;
}
Empty file.
8 changes: 8 additions & 0 deletions test/diagnostics-tests/jsdoc-extends/src/usage.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ClassicMixin from './classic-mixin.gjs';

export const Ok = <template><ClassicMixin @channelName="hi" /></template>;

export const Wrong = <template>
{{! @glint-expect-error: channelName is a string }}
<ClassicMixin @channelName={{1}} />
</template>;
16 changes: 16 additions & 0 deletions test/diagnostics-tests/jsdoc-extends/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "@tsconfig/ember/tsconfig.json",
"compilerOptions": {
"noEmit": true,
"skipLibCheck": true,
"allowJs": true,
"checkJs": true
},
"contentMappers": [
{
"package": "ember-content-mapper",
"extensions": [".gts", ".gjs"]
}
],
"include": ["src"]
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
diagnostics-tests/upstream-differences/src/conditional-element-generic.gts(21,20): error TS2345: Argument of type '{ id: "x"; }' is not assignable to parameter of type 'Partial<((IsFake extends true ? HTMLDivElement : HTMLButtonElement) extends null ? unknown : IsFake extends true ? HTMLDivElement : HTMLButtonElement) extends HTMLElement ? ... : ...>'.
diagnostics-tests/upstream-differences/src/curried-component-content.gts(5,28): error TS2345: Argument of type 'Invokable<(named?: PrebindArgs<{ a: string; b?: string | undefined; }, "a"> | undefined) => ComponentReturn<FlattenBlockParams<{}>, unknown>>' is not assignable to parameter of type 'ContentValue'.
diagnostics-tests/upstream-differences/src/jsdoc-extends-expression-heritage.gts(3,28): error TS2554: Expected 0 arguments, but got 1.
diagnostics-tests/upstream-differences/src/record-string-never-args.gts(8,57): error TS2345: Argument of type '[]' is not assignable to parameter of type 'never'.

This file was deleted.

Loading
Loading