Created
September 27, 2023 13:25
-
-
Save donaldpipowitch/e6e796df3dedc2ce87dea27c825c4d14 to your computer and use it in GitHub Desktop.
A VS Code extension which checks if a React component was created by styled-components.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as vscode from 'vscode'; | |
import * as ts from 'typescript'; | |
import * as path from 'path'; | |
export function activate(context: vscode.ExtensionContext) { | |
const decorationType = vscode.window.createTextEditorDecorationType({ | |
light: { | |
before: { | |
contentText: 'S.', | |
color: 'rgba(255, 0, 255, 0.7)', | |
}, | |
}, | |
dark: { | |
before: { | |
contentText: 'S.', | |
color: 'rgba(255, 0, 255, 0.7)', | |
}, | |
}, | |
}); | |
let timeout: NodeJS.Timer | undefined = undefined; | |
let activeEditor = vscode.window.activeTextEditor; | |
function updateDecorations() { | |
if (!activeEditor) return; | |
const { document } = activeEditor; | |
if (!document) return; | |
const components = getComponents(document); | |
const decorations: vscode.DecorationOptions[] = components.map( | |
(component) => { | |
const range = new vscode.Range( | |
document.positionAt(component.node.getStart() + 1), | |
document.positionAt(component.node.getStart() + 1), | |
); | |
const decoration: vscode.DecorationOptions = { | |
range, | |
hoverMessage: 'styled-component', | |
}; | |
return decoration; | |
}, | |
); | |
activeEditor.setDecorations(decorationType, decorations); | |
} | |
function triggerUpdateDecorations(throttle = false) { | |
if (timeout) { | |
clearTimeout(timeout); | |
timeout = undefined; | |
} | |
if (throttle) { | |
timeout = setTimeout(updateDecorations, 500); | |
} else { | |
updateDecorations(); | |
} | |
} | |
if (activeEditor) { | |
triggerUpdateDecorations(); | |
} | |
vscode.window.onDidChangeActiveTextEditor( | |
(editor) => { | |
activeEditor = editor; | |
if (editor) { | |
triggerUpdateDecorations(); | |
} | |
}, | |
null, | |
context.subscriptions, | |
); | |
vscode.workspace.onDidChangeTextDocument( | |
(event) => { | |
if (activeEditor && event.document === activeEditor.document) { | |
triggerUpdateDecorations(true); | |
} | |
}, | |
null, | |
context.subscriptions, | |
); | |
} | |
export function deactivate() {} | |
type ReactComponent = { | |
node: ts.JsxOpeningElement | ts.JsxSelfClosingElement; | |
symbol: ts.Symbol; | |
importSpecifier?: ts.ImportSpecifier; | |
}; | |
function getComponents(document: vscode.TextDocument): ReactComponent[] { | |
{ | |
const reactComponents: ReactComponent[] = []; | |
const program = ts.createProgram({ | |
rootNames: ts.sys.readDirectory(path.dirname(document.uri.fsPath), [ | |
'.ts', | |
'.tsx', | |
]), | |
options: {}, | |
}); | |
const sourceFile = program.getSourceFile(document.uri.fsPath); | |
if (!sourceFile) { | |
return reactComponents; | |
} | |
function visit(node: ts.Node) { | |
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { | |
// given the node of the opening element, find the implementation of it | |
const symbol = program | |
.getTypeChecker() | |
.getSymbolAtLocation(node.tagName); | |
if (symbol) { | |
const importSpecifier = symbol.declarations?.find((declaration) => | |
ts.isImportSpecifier(declaration), | |
) as ts.ImportSpecifier | undefined; | |
reactComponents.push({ | |
node, | |
symbol, | |
importSpecifier, | |
}); | |
} | |
} | |
ts.forEachChild(node, visit); | |
} | |
visit(sourceFile); | |
return reactComponents.filter((component) => { | |
// find the implementation of my component considering the import specifier | |
const implementationSourceFile = component.importSpecifier | |
? getImplementationSourceFile(program, component.importSpecifier) | |
: component.node.getSourceFile(); | |
if (!implementationSourceFile) return false; | |
const implementationVariableDeclaration = findImplementation( | |
implementationSourceFile, | |
component.symbol.name, | |
Boolean(component.importSpecifier), | |
); | |
if (!implementationVariableDeclaration) return false; | |
if ( | |
implementationVariableDeclaration.initializer && | |
ts.isTaggedTemplateExpression( | |
implementationVariableDeclaration.initializer, | |
) && | |
ts.isPropertyAccessExpression( | |
implementationVariableDeclaration.initializer.tag, | |
) && | |
ts.isIdentifier( | |
implementationVariableDeclaration.initializer.tag.expression, | |
) && | |
implementationVariableDeclaration.initializer.tag.expression.text === | |
'styled' | |
) | |
return true; | |
return false; | |
}); | |
} | |
} | |
// code lens is currently not used, but works | |
class ReactComponentCodeLensProvider implements vscode.CodeLensProvider { | |
async provideCodeLenses( | |
document: vscode.TextDocument, | |
token: vscode.CancellationToken, | |
): Promise<vscode.CodeLens[]> { | |
const components = getComponents(document); | |
const codeLenses: vscode.CodeLens[] = components.map((component) => { | |
const range = new vscode.Range( | |
document.positionAt(component.node.getStart()), | |
document.positionAt(component.node.getEnd()), | |
); | |
const command: vscode.Command = { | |
title: 'styled-component', | |
command: '', | |
arguments: [], | |
}; | |
const codeLens = new vscode.CodeLens(range, command); | |
return codeLens; | |
}); | |
return codeLenses; | |
} | |
} | |
function getImplementationSourceFile( | |
program: ts.Program, | |
importSpecifier: ts.ImportSpecifier, | |
): ts.SourceFile | undefined { | |
const importDeclaration = importSpecifier?.parent?.parent?.parent; | |
if (!importDeclaration) return; | |
if (!ts.isStringLiteral(importDeclaration.moduleSpecifier)) return; | |
const sourceFilePath = resolveModuleSpecifierToFilePath( | |
program, | |
importDeclaration.moduleSpecifier.text, | |
importDeclaration.getSourceFile().fileName, | |
); | |
if (!sourceFilePath) return; | |
return program.getSourceFile(sourceFilePath); | |
} | |
function resolveModuleSpecifierToFilePath( | |
program: ts.Program, | |
moduleSpecifierText: string, | |
containingFilePath: string, | |
): string | undefined { | |
const compilerOptions = program.getCompilerOptions(); | |
const resolvedModule = ts.resolveModuleName( | |
moduleSpecifierText, | |
containingFilePath, | |
compilerOptions, | |
ts.sys, | |
); | |
if (resolvedModule.resolvedModule) { | |
const resolvedFileName = resolvedModule.resolvedModule.resolvedFileName; | |
if (resolvedFileName.endsWith('.d.ts')) { | |
// Don't try to read .d.ts files | |
return undefined; | |
} | |
return resolvedFileName; | |
} | |
return undefined; | |
} | |
function findImplementation( | |
sourceFile: ts.SourceFile, | |
componentName: string, | |
exportOnly: boolean, | |
): ts.VariableDeclaration | void { | |
for (const statement of sourceFile.statements) { | |
if ( | |
ts.isVariableStatement(statement) && | |
(exportOnly ? hasExportModifier(statement) : true) | |
) { | |
const declarations = statement.declarationList.declarations; | |
for (const declaration of declarations) { | |
if ( | |
ts.isIdentifier(declaration.name) && | |
declaration.name.text === componentName | |
) { | |
return declaration; | |
} | |
} | |
} | |
} | |
} | |
function hasExportModifier(node: ts.VariableStatement): boolean { | |
return ( | |
node.modifiers?.some( | |
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, | |
) ?? false | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment