@glint/core
Version:
A CLI for performing typechecking on Glimmer templates
175 lines • 9.31 kB
JavaScript
/**
* Given a diagnostic and a mapping tree node corresponding to its location,
* returns updated message text for that diagnostic with Glint-specific
* information included, if applicable.
*/
export function augmentDiagnostic(diagnostic, mapping) {
return {
...diagnostic,
messageText: rewriteMessageText(diagnostic, mapping),
};
}
function rewriteMessageText(diagnostic, mapping) {
return diagnosticHandlers[diagnostic.code]?.(diagnostic, mapping) ?? diagnostic.messageText;
}
const diagnosticHandlers = {
2322: checkAssignabilityError,
2345: checkAssignabilityError,
2554: noteNamedArgsAffectArity,
2555: noteNamedArgsAffectArity,
2769: checkResolveError,
4111: checkIndexAccessError,
7053: checkImplicitAnyError, // TS7053: Element implicitly has an 'any' type because expression of type '"X"' can't be used to index type 'Y'.
};
const bindHelpers = ['component', 'helper', 'modifier'];
function checkAssignabilityError(message, mapping) {
let node = mapping.sourceNode;
let parentNode = mapping.parent?.sourceNode;
if (!parentNode)
return;
if (node.type === 'Identifier' &&
parentNode.type === 'AttrNode' &&
!/^(@|\.)/.test(parentNode.name)) {
// If the assignability issue is on an attribute name and it's not an `@arg`
// or `...attributes`, then it's an HTML attribute type issue.
return addGlintDetails(message, 'Only primitive values (see `AttrValue` in `@glint/template`) are assignable as HTML attributes. ' +
'If you want to set an event listener, consider using the `{{on}}` modifier instead.');
}
else if (node.type === 'MustacheStatement' &&
(parentNode.type === 'Template' ||
parentNode.type === 'BlockStatement' ||
parentNode.type === 'ElementNode') &&
!(node.path.type === 'PathExpression' && node.path.original === 'yield')) {
// If we're looking at a top-level {{mustache}}, we first double check whether
// it's an attempted inline {{component 'foo'}} invocation...
if (node.path.type === 'PathExpression' && node.path.original === 'component') {
return addGlintDetails(message, `The {{component}} helper can't be used to directly invoke a component under Glint. ` +
`Consider first binding the result to a variable, e.g. ` +
`'{{#let (component 'component-name') as |ComponentName|}}' and then invoking it as ` +
`'<ComponentName @arg={{value}} />'.`);
}
// Otherwise, it's a DOM content type issue.
return addGlintDetails(message, 'Only primitive values and certain DOM objects (see `ContentValue` in `@glint/template`) are ' +
'usable as top-level template content.');
}
else if ((mapping?.sourceNode.type === 'SubExpression' ||
mapping?.sourceNode.type === 'MustacheStatement') &&
mapping.sourceNode.path.type === 'PathExpression' &&
bindHelpers.includes(mapping.sourceNode.path.original)) {
// If we're looking at a binding helper subexpression like `(component ...)`, error messages
// may be very straightforward or may be horrendously complex when users start playing games
// with parametrized types, so we add a hint here.
let kind = mapping.sourceNode.path.original;
return addGlintDetails(message, `Unable to pre-bind the given args to the given ${kind}. This likely indicates a type ` +
`mismatch between its signature and the values you're passing.`);
}
}
function noteNamedArgsAffectArity(diagnostic, mapping) {
// In normal template entity invocations, named args (if specified) are effectively
// passed as the final positional argument. Because of this, the reported "expected
// N arguments, but got M" message may appear to be off-by-one to the developer.
// Since this treatment of named args as a final "options hash" argument is user
// visible in cases like type errors, we can't just paper over this by subtracting
// 1 from the numbers in the message. Instead, if the invocation has named args, we
// explicitly note that they're effectively the final positional parameter.
let callNode = findAncestor(mapping, 'MustacheStatement', 'SubExpression');
if (callNode?.path.type === 'PathExpression' && callNode.path.original === 'yield') {
// Yield is directly transformed rather than being treated as a normal mustache
return;
}
let hasNamedArgs = !!callNode?.hash.pairs.length;
if (hasNamedArgs) {
let note = 'Note that named args are passed together as a final argument, so they ' +
'collectively increase the given arg count by 1.';
if (typeof diagnostic.messageText === 'string') {
return `${diagnostic.messageText} ${note}`;
}
else {
return {
...diagnostic.messageText,
messageText: `${diagnostic.messageText.messageText} ${note}`,
};
}
}
}
function checkResolveError(diagnostic, mapping) {
// The diagnostic might fall on a lone identifier or a full path; if the former,
// we need to traverse up through the path to find the true parent.
let sourceMapping = mapping.sourceNode.type === 'Identifier' ? mapping.parent : mapping;
let parentNode = sourceMapping?.parent?.sourceNode;
// If this error is on the first param to a {{component}} or other bind invocation, this means
// we either have a non-component value or a string that's missing from the registry.
if ((parentNode?.type === 'SubExpression' || parentNode?.type === 'MustacheStatement') &&
parentNode.path.type === 'PathExpression' &&
bindHelpers.includes(parentNode.path.original) &&
parentNode.params[0] === sourceMapping?.sourceNode) {
let kind = parentNode.path.original;
if (sourceMapping.sourceNode.type === 'StringLiteral') {
return addGlintDetails(diagnostic, `Unknown ${kind} name '${sourceMapping.sourceNode.value}'. If this isn't a typo, you may be ` +
`missing a registry entry for this name; see the Template Registry page in the Glint ` +
`documentation for more details.`);
}
else {
return addGlintDetails(diagnostic, `The type of this expression doesn't appear to be a valid value to pass the {{${kind}}} ` +
`helper. If possible, you may need to give the expression a narrower type, ` +
`for example \`'thing-a' | 'thing-b'\` rather than \`string\`.`);
}
}
// Otherwise if this is on a top level invocation, we're trying to use a template-unaware
// value in a template-specific way.
let nodeType = sourceMapping?.sourceNode.type;
if (nodeType === 'ElementNode' || nodeType === 'PathExpression' || nodeType === 'Identifier') {
return addGlintDetails(diagnostic, 'The given value does not appear to be usable as a component, modifier or helper.');
}
}
function checkImplicitAnyError(diagnostic, mapping) {
let messageText = typeof diagnostic.messageText === 'string'
? diagnostic.messageText
: diagnostic.messageText.messageText;
// We don't want to bake in assumptions about the exact format of TS error messages,
// but we can assume that the name of the type we're indexing (`Globals`) will appear
// in the text in the case we're interested in.
if (messageText.includes('Globals')) {
let { sourceNode } = mapping;
// This error may appear either on `<Foo />` or `{{foo}}`/`(foo)`
let globalName = sourceNode.type === 'ElementNode'
? sourceNode.tag.split('.')[0]
: sourceNode.type === 'PathExpression' && sourceNode.head.type === 'VarHead'
? sourceNode.head.name
: null;
if (globalName) {
return addGlintDetails(diagnostic, `Unknown name '${globalName}'. If this isn't a typo, you may be missing a registry entry ` +
`for this value; see the Template Registry page in the Glint documentation for more details.`);
}
}
}
function checkIndexAccessError(diagnostic, mapping) {
if (mapping.sourceNode.type === 'Identifier') {
let messageText = typeof diagnostic.messageText === 'string'
? diagnostic.messageText
: diagnostic.messageText.messageText;
// "accessed with ['x']" => "accessed with {{get ... 'x'}}"
return messageText.replace(/\[(['"])(.*)\1\]/, `{{get ... $1$2$1}}`);
}
}
function addGlintDetails(diagnostic, details) {
let { category, code, messageText } = diagnostic;
return {
category,
code,
messageText: details,
next: [typeof messageText === 'string' ? { category, code, messageText } : messageText],
};
}
// Find the nearest mapping node at or above the given one whose `source` AST node
// matches one of the given types.
function findAncestor(mapping, ...types) {
let current = mapping;
do {
if (types.includes(current.sourceNode.type)) {
return current.sourceNode;
}
} while ((current = current.parent));
return null;
}
//# sourceMappingURL=augmentation.js.map