Skip to content

Instantly share code, notes, and snippets.

@donaldpipowitch
Created September 27, 2023 13:25
Show Gist options
  • Save donaldpipowitch/e6e796df3dedc2ce87dea27c825c4d14 to your computer and use it in GitHub Desktop.
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.
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