blob: 668fc40b9d53fb21d8befbec8e5d7d5f91e81ba1 [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @file Rule to forbid passing `classes` or `jslog` in `WidgetOptions` to
* `super(...)` in `UI.Widget` subclasses that use a Lit view function,
* requiring `{container: {classes: [...], attributes: {jslog: ...}}}` in the
* third parameter of `render(...)` instead.
*/
import type {TSESLint, TSESTree} from '@typescript-eslint/utils';
import {isLitHtmlRenderCall} from './utils/lit.ts';
import {createRule} from './utils/ruleCreator.ts';
type MessageIds = 'noWidgetConstructorClasses'|'noWidgetConstructorJslog';
type RuleOptions = [];
function findObjectProperty(optionsObj: TSESTree.ObjectExpression, propName: string): TSESTree.Property|undefined {
for (const prop of optionsObj.properties) {
if (prop.type === 'Property' && !prop.computed && prop.key.type === 'Identifier' && prop.key.name === propName) {
return prop;
}
}
return undefined;
}
function hasShadowRootSuperTypeArg(classNode: TSESTree.ClassDeclaration|TSESTree.ClassExpression): boolean {
const params = classNode.superTypeArguments?.params;
if (!params || params.length === 0) {
return false;
}
const firstParam = params[0];
return firstParam.type === 'TSTypeReference' && firstParam.typeName.type === 'Identifier' &&
(firstParam.typeName.name === 'ShadowRoot' || firstParam.typeName.name === 'DocumentFragment');
}
function hasUseShadowDomTrue(optionsObj: TSESTree.ObjectExpression): boolean {
const useShadowDomProp = findObjectProperty(optionsObj, 'useShadowDom');
return Boolean(useShadowDomProp && useShadowDomProp.value.type === 'Literal' &&
useShadowDomProp.value.value === true);
}
function isTopLevelVariableDeclarator(node: TSESTree.VariableDeclarator): boolean {
const decl = node.parent;
if (decl?.type !== 'VariableDeclaration') {
return false;
}
return decl.parent?.type === 'Program' || decl.parent?.type === 'ExportNamedDeclaration';
}
function isTopLevelFunctionDeclaration(node: TSESTree.FunctionDeclaration): boolean {
return node.parent?.type === 'Program' || node.parent?.type === 'ExportNamedDeclaration';
}
function collectPatternIdentifiers(pattern: TSESTree.Node, out: Set<string>): void {
switch (pattern.type) {
case 'Identifier':
out.add(pattern.name);
break;
case 'AssignmentPattern':
collectPatternIdentifiers(pattern.left, out);
break;
case 'RestElement':
collectPatternIdentifiers(pattern.argument, out);
break;
case 'ArrayPattern':
for (const el of pattern.elements) {
if (el) {
collectPatternIdentifiers(el, out);
}
}
break;
case 'ObjectPattern':
for (const prop of pattern.properties) {
if (prop.type === 'Property') {
collectPatternIdentifiers(prop.value, out);
} else if (prop.type === 'RestElement') {
collectPatternIdentifiers(prop.argument, out);
}
}
break;
case 'TSParameterProperty':
collectPatternIdentifiers(pattern.parameter, out);
break;
default:
break;
}
}
function canMoveExpressionToModuleScope(
expr: TSESTree.Node,
constructorNode: TSESTree.MethodDefinition,
sourceCode: TSESLint.SourceCode,
): boolean {
const constructorDeclaredNames = new Set<string>();
for (const param of constructorNode.value.params) {
collectPatternIdentifiers(param, constructorDeclaredNames);
}
const walkForDeclarations = (node: TSESTree.Node): void => {
if (node.type === 'VariableDeclarator') {
collectPatternIdentifiers(node.id, constructorDeclaredNames);
} else if (node.type === 'FunctionDeclaration' && node.id) {
constructorDeclaredNames.add(node.id.name);
}
const keys = sourceCode.visitorKeys[node.type] ?? [];
for (const key of keys) {
const child = (node as unknown as Record<string, unknown>)[key];
if (Array.isArray(child)) {
for (const item of child) {
if (item && typeof item === 'object' && 'type' in item) {
walkForDeclarations(item as TSESTree.Node);
}
}
} else if (child && typeof child === 'object' && 'type' in child) {
walkForDeclarations(child as TSESTree.Node);
}
}
};
if (constructorNode.value.body) {
walkForDeclarations(constructorNode.value.body);
}
const checkExpr = (node: TSESTree.Node, parent?: TSESTree.Node): boolean => {
if (node.type === 'ThisExpression' || node.type === 'Super') {
return false;
}
if (node.type === 'Identifier') {
const isNonComputedMemberProp =
parent?.type === 'MemberExpression' && !parent.computed && parent.property === node;
const isNonComputedObjectKey = parent?.type === 'Property' && !parent.computed && parent.key === node;
if (!isNonComputedMemberProp && !isNonComputedObjectKey && constructorDeclaredNames.has(node.name)) {
return false;
}
}
const keys = sourceCode.visitorKeys[node.type] ?? [];
for (const key of keys) {
const child = (node as unknown as Record<string, unknown>)[key];
if (Array.isArray(child)) {
for (const item of child) {
if (item && typeof item === 'object' && 'type' in item) {
if (!checkExpr(item as TSESTree.Node, node)) {
return false;
}
}
}
} else if (child && typeof child === 'object' && 'type' in child) {
if (!checkExpr(child as TSESTree.Node, node)) {
return false;
}
}
}
return true;
};
return checkExpr(expr);
}
function buildSuperFix(
fixer: TSESLint.RuleFixer,
sourceCode: TSESLint.SourceCode,
superCall: TSESTree.CallExpression,
optionsObj: TSESTree.ObjectExpression,
propsToRemove: TSESTree.Property[],
optionsArgIndex: number,
shouldConvertUseShadowDomToPure: boolean,
): TSESLint.RuleFix[] {
const removeSet = new Set<TSESTree.ObjectLiteralElement>(propsToRemove);
const allProps = optionsObj.properties;
if (allProps.length === removeSet.size) {
if (optionsArgIndex === 0) {
return [fixer.replaceText(superCall, 'super()')];
}
const firstArg = superCall.arguments[0];
if (firstArg.type === 'Identifier' && firstArg.name === 'undefined') {
return [fixer.replaceText(superCall, 'super()')];
}
const firstArgText = sourceCode.getText(firstArg);
return [fixer.replaceText(superCall, `super(${firstArgText})`)];
}
const useShadowDomProp = findObjectProperty(optionsObj, 'useShadowDom');
// If only one property is being removed, remove its range cleanly.
if (propsToRemove.length === 1) {
const prop = propsToRemove[0];
const idx = allProps.indexOf(prop);
const fixes: TSESLint.RuleFix[] = [];
if (shouldConvertUseShadowDomToPure && useShadowDomProp) {
fixes.push(fixer.replaceText(useShadowDomProp.value, '\'pure\''));
}
if (idx < allProps.length - 1) {
const nextProp = allProps[idx + 1];
fixes.push(fixer.removeRange([prop.range[0], nextProp.range[0]]));
return fixes;
}
const prevProp = allProps[idx - 1];
fixes.push(fixer.removeRange([prevProp.range[1], prop.range[1]]));
return fixes;
}
// Multiple properties removed with remaining properties: rebuild the object literal.
const remainingTexts = allProps.filter(p => !removeSet.has(p)).map(p => {
if (shouldConvertUseShadowDomToPure && p === useShadowDomProp) {
return 'useShadowDom: \'pure\'';
}
return sourceCode.getText(p);
});
return [fixer.replaceText(optionsObj, `{${remainingTexts.join(', ')}}`)];
}
function buildRenderFix(
fixer: TSESLint.RuleFixer,
sourceCode: TSESLint.SourceCode,
renderCall: TSESTree.CallExpression,
classesValueNode: TSESTree.Node|undefined,
jslogValueNode: TSESTree.Node|undefined,
): TSESLint.RuleFix|null {
const classesText = classesValueNode ? sourceCode.getText(classesValueNode) : undefined;
const jslogText = jslogValueNode ? sourceCode.getText(jslogValueNode) : undefined;
const buildContainerEntriesText = (): string => {
const parts: string[] = [];
if (jslogText) {
parts.push(`attributes: {jslog: ${jslogText}}`);
}
if (classesText) {
parts.push(`classes: ${classesText}`);
}
return parts.join(', ');
};
if (renderCall.arguments.length === 2) {
const secondArg = renderCall.arguments[1];
return fixer.insertTextAfter(secondArg, `, {container: {${buildContainerEntriesText()}}}`);
}
if (renderCall.arguments.length === 3) {
const thirdArg = renderCall.arguments[2];
if (thirdArg.type !== 'ObjectExpression') {
return null;
}
const containerProp = findObjectProperty(thirdArg, 'container');
if (!containerProp) {
const entryText = `container: {${buildContainerEntriesText()}}`;
if (thirdArg.properties.length === 0) {
return fixer.replaceText(thirdArg, `{${entryText}}`);
}
const lastProp = thirdArg.properties[thirdArg.properties.length - 1];
const tokenAfterLast = sourceCode.getTokenAfter(lastProp);
if (tokenAfterLast && tokenAfterLast.value === ',') {
return fixer.insertTextAfter(tokenAfterLast, ` ${entryText}`);
}
return fixer.insertTextAfter(lastProp, `, ${entryText}`);
}
if (containerProp.value.type !== 'ObjectExpression') {
return null;
}
const containerObj = containerProp.value;
// Handle single addition (either classes or jslog) into existing container object.
if (classesText && !jslogText) {
const existingClassesProp = findObjectProperty(containerObj, 'classes');
if (existingClassesProp) {
if (existingClassesProp.value.type === 'ArrayExpression' && classesValueNode?.type === 'ArrayExpression' &&
existingClassesProp.value.elements.length > 0 && classesValueNode.elements.length > 0) {
const firstExistingElem = existingClassesProp.value.elements[0];
if (!firstExistingElem) {
return null;
}
const newElementsText = classesValueNode.elements.map(el => (el ? sourceCode.getText(el) : '')).join(', ');
return fixer.insertTextBefore(firstExistingElem, `${newElementsText}, `);
}
return null;
}
if (containerObj.properties.length === 0) {
return fixer.replaceText(containerObj, `{classes: ${classesText}}`);
}
const lastContainerProp = containerObj.properties[containerObj.properties.length - 1];
const tokenAfterLast = sourceCode.getTokenAfter(lastContainerProp);
if (tokenAfterLast && tokenAfterLast.value === ',') {
return fixer.insertTextAfter(tokenAfterLast, ` classes: ${classesText}`);
}
return fixer.insertTextAfter(lastContainerProp, `, classes: ${classesText}`);
}
if (jslogText && !classesText) {
const existingAttrsProp = findObjectProperty(containerObj, 'attributes');
if (existingAttrsProp) {
if (existingAttrsProp.value.type !== 'ObjectExpression') {
return null;
}
const attrsObj = existingAttrsProp.value;
if (findObjectProperty(attrsObj, 'jslog')) {
return null;
}
if (attrsObj.properties.length === 0) {
return fixer.replaceText(attrsObj, `{jslog: ${jslogText}}`);
}
const lastAttrProp = attrsObj.properties[attrsObj.properties.length - 1];
const tokenAfterLast = sourceCode.getTokenAfter(lastAttrProp);
if (tokenAfterLast && tokenAfterLast.value === ',') {
return fixer.insertTextAfter(tokenAfterLast, ` jslog: ${jslogText}`);
}
return fixer.insertTextAfter(lastAttrProp, `, jslog: ${jslogText}`);
}
if (containerObj.properties.length === 0) {
return fixer.replaceText(containerObj, `{attributes: {jslog: ${jslogText}}}`);
}
const lastContainerProp = containerObj.properties[containerObj.properties.length - 1];
const tokenAfterLast = sourceCode.getTokenAfter(lastContainerProp);
if (tokenAfterLast && tokenAfterLast.value === ',') {
return fixer.insertTextAfter(tokenAfterLast, ` attributes: {jslog: ${jslogText}}`);
}
return fixer.insertTextAfter(lastContainerProp, `, attributes: {jslog: ${jslogText}}`);
}
if (classesText && jslogText) {
const existingClassesProp = findObjectProperty(containerObj, 'classes');
const existingAttrsProp = findObjectProperty(containerObj, 'attributes');
if (existingClassesProp) {
return null;
}
if (existingAttrsProp) {
if (existingAttrsProp.value.type !== 'ObjectExpression') {
return null;
}
const attrsObj = existingAttrsProp.value;
if (findObjectProperty(attrsObj, 'jslog')) {
return null;
}
const propTexts = containerObj.properties.map(p => {
if (p === existingAttrsProp) {
const innerTexts = attrsObj.properties.map(ap => sourceCode.getText(ap));
innerTexts.push(`jslog: ${jslogText}`);
return `attributes: {${innerTexts.join(', ')}}`;
}
return sourceCode.getText(p);
});
propTexts.push(`classes: ${classesText}`);
return fixer.replaceText(containerObj, `{${propTexts.join(', ')}}`);
}
if (containerObj.properties.length === 0) {
return fixer.replaceText(containerObj, `{${buildContainerEntriesText()}}`);
}
const lastContainerProp = containerObj.properties[containerObj.properties.length - 1];
const tokenAfterLast = sourceCode.getTokenAfter(lastContainerProp);
if (tokenAfterLast && tokenAfterLast.value === ',') {
return fixer.insertTextAfter(tokenAfterLast, ` ${buildContainerEntriesText()}`);
}
return fixer.insertTextAfter(lastContainerProp, `, ${buildContainerEntriesText()}`);
}
}
return null;
}
function getDefaultViewNameFromConstructor(constructorNode: TSESTree.MethodDefinition): string|undefined {
for (const param of constructorNode.value.params) {
if (param.type === 'AssignmentPattern' && param.left.type === 'Identifier' && param.right.type === 'Identifier') {
const paramName = param.left.name;
if (paramName === 'view' || paramName === 'viewFunction') {
return param.right.name;
}
}
}
return undefined;
}
export default createRule<RuleOptions, MessageIds>({
name: 'no-widget-constructor-classes',
meta: {
type: 'problem',
docs: {
description: 'Forbid passing `classes` or `jslog` in `WidgetOptions` to `super(...)` in `UI.Widget` ' +
'subclasses that use a Lit view function; pass `{container: {classes: [...], attributes: {jslog: ...}}}` ' +
'to `render(...)` instead.',
category: 'Possible Errors',
},
messages: {
noWidgetConstructorClasses: 'Pass host `classes` via the third parameter of ' +
'`render(template, target, {container: {classes: [...]}})` in the view function ' +
'instead of the `Widget` constructor.',
noWidgetConstructorJslog: 'Pass host `jslog` via the third parameter of ' +
'`render(template, target, {container: {attributes: {jslog: ...}}})` in the view function ' +
'instead of the `Widget` constructor.',
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create: function(context) {
const sourceCode = context.sourceCode;
const allTopLevelRenderCalls: TSESTree.CallExpression[] = [];
const renderCallsByTopLevelFn = new Map<string, TSESTree.CallExpression[]>();
const classesWithView = new Set<TSESTree.ClassDeclaration|TSESTree.ClassExpression>();
const superCandidates: Array<{
classNode: TSESTree.ClassDeclaration | TSESTree.ClassExpression,
constructorNode: TSESTree.MethodDefinition,
superCall: TSESTree.CallExpression,
optionsObj: TSESTree.ObjectExpression,
classesProp: TSESTree.Property | undefined,
jslogProp: TSESTree.Property | undefined,
optionsArgIndex: number,
}> = [];
let currentClass: TSESTree.ClassDeclaration|TSESTree.ClassExpression|null = null;
let currentConstructor: TSESTree.MethodDefinition|null = null;
let currentTopLevelFnName: string|null = null;
return {
VariableDeclarator(node: TSESTree.VariableDeclarator) {
if (!currentClass && isTopLevelVariableDeclarator(node) && node.id.type === 'Identifier' && node.init &&
(node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression')) {
currentTopLevelFnName = node.id.name;
}
},
'VariableDeclarator:exit'(node: TSESTree.VariableDeclarator) {
if (!currentClass && isTopLevelVariableDeclarator(node) && node.id.type === 'Identifier' &&
currentTopLevelFnName === node.id.name) {
currentTopLevelFnName = null;
}
},
FunctionDeclaration(node: TSESTree.FunctionDeclaration) {
if (!currentClass && isTopLevelFunctionDeclaration(node) && node.id) {
currentTopLevelFnName = node.id.name;
}
},
'FunctionDeclaration:exit'(node: TSESTree.FunctionDeclaration) {
if (!currentClass && isTopLevelFunctionDeclaration(node) && node.id && currentTopLevelFnName === node.id.name) {
currentTopLevelFnName = null;
}
},
'ClassDeclaration, ClassExpression'(node: TSESTree.ClassDeclaration|TSESTree.ClassExpression) {
currentClass = node;
},
'ClassDeclaration, ClassExpression:exit'() {
currentClass = null;
},
PropertyDefinition(node: TSESTree.PropertyDefinition) {
if (!currentClass) {
return;
}
const keyName =
(node.key.type === 'PrivateIdentifier' || node.key.type === 'Identifier') ? node.key.name : undefined;
if (keyName === 'view') {
classesWithView.add(currentClass);
}
},
'MethodDefinition[kind="constructor"]'(node: TSESTree.MethodDefinition) {
currentConstructor = node;
if (currentClass && getDefaultViewNameFromConstructor(node)) {
classesWithView.add(currentClass);
}
},
'MethodDefinition[kind="constructor"]:exit'() {
currentConstructor = null;
},
CallExpression(node: TSESTree.CallExpression) {
if (!currentClass && isLitHtmlRenderCall(node) && node.arguments.length >= 2) {
allTopLevelRenderCalls.push(node);
if (currentTopLevelFnName) {
const list = renderCallsByTopLevelFn.get(currentTopLevelFnName) ?? [];
list.push(node);
renderCallsByTopLevelFn.set(currentTopLevelFnName, list);
}
return;
}
if (currentClass && node.callee.type === 'MemberExpression' && !node.callee.computed &&
node.callee.object.type === 'ThisExpression' &&
(node.callee.property.type === 'PrivateIdentifier' || node.callee.property.type === 'Identifier') &&
node.callee.property.name === 'view') {
classesWithView.add(currentClass);
}
if (!currentConstructor || !currentClass || !currentClass.superClass || node.callee.type !== 'Super') {
return;
}
for (let i = 0; i < Math.min(node.arguments.length, 2); i++) {
const arg = node.arguments[i];
if (arg.type === 'ObjectExpression') {
const classesProp = findObjectProperty(arg, 'classes');
const jslogProp = findObjectProperty(arg, 'jslog');
if (classesProp || jslogProp) {
superCandidates.push({
classNode: currentClass,
constructorNode: currentConstructor,
superCall: node,
optionsObj: arg,
classesProp,
jslogProp,
optionsArgIndex: i,
});
break;
}
}
}
},
'Program:exit'() {
if (superCandidates.length === 0 || allTopLevelRenderCalls.length === 0) {
return;
}
const resolveTargetRenderCall =
(candidate: (typeof superCandidates)[number]): TSESTree.CallExpression|undefined => {
const viewFnName = getDefaultViewNameFromConstructor(candidate.constructorNode);
if (viewFnName) {
const calls = renderCallsByTopLevelFn.get(viewFnName);
if (calls && calls.length > 0) {
return calls.length === 1 ? calls[0] : undefined;
}
}
if (superCandidates.length === 1 && allTopLevelRenderCalls.length === 1) {
return allTopLevelRenderCalls[0];
}
return undefined;
};
for (const candidate of superCandidates) {
if (!classesWithView.has(candidate.classNode)) {
continue;
}
const targetRenderCall = resolveTargetRenderCall(candidate);
const {classesProp, jslogProp, constructorNode} = candidate;
const isShadowDomTrue = hasUseShadowDomTrue(candidate.optionsObj);
const hasShadowRootType = hasShadowRootSuperTypeArg(candidate.classNode);
const canAutofixShadowDomClasses = !isShadowDomTrue || hasShadowRootType;
const shouldConvertToPure = Boolean(classesProp && isShadowDomTrue && hasShadowRootType);
const canMoveClasses = (classesProp && canAutofixShadowDomClasses) ?
canMoveExpressionToModuleScope(classesProp.value, constructorNode, sourceCode) :
false;
const canMoveJslog =
jslogProp ? canMoveExpressionToModuleScope(jslogProp.value, constructorNode, sourceCode) : false;
if (classesProp && jslogProp) {
// Report both properties; attach the combined autofix to the first property so fixes don't conflict.
context.report({
node: classesProp,
messageId: 'noWidgetConstructorClasses',
fix: (targetRenderCall && canMoveClasses && canMoveJslog) ?
fixer => {
const renderFix =
buildRenderFix(fixer, sourceCode, targetRenderCall, classesProp.value, jslogProp.value);
if (!renderFix) {
return null;
}
const superFixes = buildSuperFix(
fixer,
sourceCode,
candidate.superCall,
candidate.optionsObj,
[classesProp, jslogProp],
candidate.optionsArgIndex,
shouldConvertToPure,
);
return [renderFix, ...superFixes];
} :
null,
});
context.report({
node: jslogProp,
messageId: 'noWidgetConstructorJslog',
});
} else if (classesProp) {
context.report({
node: classesProp,
messageId: 'noWidgetConstructorClasses',
fix: (targetRenderCall && canMoveClasses) ?
fixer => {
const renderFix = buildRenderFix(fixer, sourceCode, targetRenderCall, classesProp.value, undefined);
if (!renderFix) {
return null;
}
const superFixes = buildSuperFix(
fixer,
sourceCode,
candidate.superCall,
candidate.optionsObj,
[classesProp],
candidate.optionsArgIndex,
shouldConvertToPure,
);
return [renderFix, ...superFixes];
} :
null,
});
} else if (jslogProp) {
context.report({
node: jslogProp,
messageId: 'noWidgetConstructorJslog',
fix: (targetRenderCall && canMoveJslog) ?
fixer => {
const renderFix = buildRenderFix(fixer, sourceCode, targetRenderCall, undefined, jslogProp.value);
if (!renderFix) {
return null;
}
const superFixes = buildSuperFix(
fixer,
sourceCode,
candidate.superCall,
candidate.optionsObj,
[jslogProp],
candidate.optionsArgIndex,
false,
);
return [renderFix, ...superFixes];
} :
null,
});
}
}
},
};
},
});