UNPKG

@glint/core

Version:

A CLI for performing typechecking on Glimmer templates

175 lines 9.31 kB
/** * 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