@prometx/trpc-navigation-plugin
Version:
TypeScript Language Service Plugin that fixes broken 'go to definition' for tRPC when using declaration emit
256 lines • 12.4 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
const ts = __importStar(require("typescript/lib/tsserverlibrary"));
const config_1 = require("./config");
const logger_1 = require("./logger");
const navigation_utils_1 = require("./navigation-utils");
const navigator_1 = require("./navigator");
const type_resolver_1 = require("./type-resolver");
function create(info) {
// Read configuration
const config = info.config || {};
const pluginConfig = (0, config_1.getConfigWithDefaults)(config);
const logger = (0, logger_1.createLogger)(info, pluginConfig.verbose);
logger.info('TRPC Navigation Plugin initialized');
// Validate configuration
if (!pluginConfig.router) {
logger.error('TRPC Navigation Plugin requires router configuration');
logger.error('Add to your tsconfig.json:');
logger.error('"plugins": [{');
logger.error(' "name": "trpc-navigation-plugin",');
logger.error(' "router": {');
logger.error(' "filePath": "./src/server/api/root.ts",');
logger.error(' "variableName": "appRouter"');
logger.error(' }');
logger.error('}]');
// Return the original language service without modifications
return info.languageService;
}
// Validate router configuration
if (!pluginConfig.router.filePath || !pluginConfig.router.variableName) {
logger.error('Invalid router configuration: both filePath and variableName are required');
return info.languageService;
}
const typeResolver = new type_resolver_1.TypeResolver(logger, info.serverHost, pluginConfig);
const navigator = new navigator_1.Navigator(logger, info.serverHost, pluginConfig);
// Proxy the language service
const proxy = Object.create(null);
for (const k of Object.keys(info.languageService)) {
const x = info.languageService[k];
// @ts-ignore - TypeScript's type system can't properly handle this proxy pattern
proxy[k] = (...args) => x.apply(info.languageService, args);
}
// Helper to find variable declaration
function findVariableDeclaration(sourceFile, variableName) {
let result;
function visit(node) {
if (result)
return;
if (ts.isVariableStatement(node)) {
for (const decl of node.declarationList.declarations) {
if (ts.isIdentifier(decl.name) && decl.name.text === variableName) {
result = decl;
return;
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return result;
}
// Helper to find useUtils variables
function findUseUtilsVariables(sourceFile) {
const utilsVariables = new Set();
function visit(node) {
if (ts.isVariableStatement(node)) {
node.declarationList.declarations.forEach((decl) => {
if (ts.isIdentifier(decl.name) && decl.initializer) {
if (ts.isCallExpression(decl.initializer)) {
const expr = decl.initializer.expression;
if (ts.isPropertyAccessExpression(expr) && expr.name.text === pluginConfig.patterns.utilsMethod) {
utilsVariables.add(decl.name.text);
logger.info(`Found useUtils variable: ${decl.name.text}`);
}
}
}
});
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return utilsVariables;
}
// Override getDefinitionAndBoundSpan for navigation
proxy.getDefinitionAndBoundSpan = (fileName, position) => {
try {
// Skip non-supported files
const extensionPattern = pluginConfig.fileExtensions.map((ext) => ext.replace('.', '\\.')).join('|');
if (!fileName.match(new RegExp(`(${extensionPattern})$`))) {
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
const program = info.languageService.getProgram();
if (!program) {
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
const sourceFile = program.getSourceFile(fileName);
if (!sourceFile) {
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
const typeChecker = program.getTypeChecker();
const text = sourceFile.text;
// Find the line containing the position
const lineStart = text.lastIndexOf('\n', position) + 1;
const lineEnd = text.indexOf('\n', position);
const line = text.substring(lineStart, lineEnd === -1 ? text.length : lineEnd);
const cursorPositionInLine = position - lineStart;
// Detect tRPC API call
const apiCall = (0, navigation_utils_1.detectTrpcApiCall)(line, cursorPositionInLine);
if (!apiCall) {
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
// Check if it's a tRPC client or useUtils variable
const utilsVariables = findUseUtilsVariables(sourceFile);
const isTrpcClient = typeResolver.isTrpcClient(apiCall.variable, sourceFile, position, typeChecker);
const isUtilsVar = utilsVariables.has(apiCall.variable);
if (!isTrpcClient && !isUtilsVar) {
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
// Find which word was clicked
const clickedWord = (0, navigation_utils_1.findWordAtPosition)(text, position);
const fullPath = `${apiCall.variable}.${apiCall.path}`;
// Parse the navigation path
const navPath = (0, navigation_utils_1.parseNavigationPath)(fullPath, clickedWord.word, apiCall.variable);
if (!navPath) {
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
logger.debug(`Navigating to: ${navPath.targetPath.join('.')}`);
// For useUtils variables, we need to find the original tRPC client
let targetVariable = apiCall.variable;
if (isUtilsVar) {
logger.debug(`🔄 Tracing useUtils variable: ${targetVariable}`);
// Find the declaration of the utils variable
const utilsDecl = findVariableDeclaration(sourceFile, targetVariable);
if (utilsDecl?.initializer && ts.isCallExpression(utilsDecl.initializer)) {
const expr = utilsDecl.initializer.expression;
// Check if it's variableName.useUtils()
if (ts.isPropertyAccessExpression(expr) &&
expr.name.text === pluginConfig.patterns.utilsMethod &&
ts.isIdentifier(expr.expression)) {
targetVariable = expr.expression.text;
logger.info(`✅ Traced useUtils to tRPC client: ${targetVariable}`);
}
}
if (targetVariable === apiCall.variable) {
logger.error('Could not trace useUtils to tRPC client');
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
}
// Extract router type from the tRPC client (or traced variable)
const routerInfo = typeResolver.extractRouterType(targetVariable, sourceFile, position, typeChecker, program);
if (!routerInfo) {
logger.error('Could not extract router type', {
variable: apiCall.variable,
fileName: sourceFile.fileName,
position,
fullPath,
targetPath: navPath.targetPath.join('.'),
clickedWord: clickedWord.word,
});
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
// Navigate through the router
const definition = navigator.navigateRouterPath(routerInfo.routerSymbol, navPath.targetPath, typeChecker);
if (!definition) {
logger.error('Navigation failed');
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
logger.info(`Navigation success: ${definition.fileName}`);
return (0, navigation_utils_1.createNavigationResult)(definition, clickedWord);
}
catch (error) {
logger.error('Error in getDefinitionAndBoundSpan', error);
return info.languageService.getDefinitionAndBoundSpan(fileName, position);
}
};
// Override getQuickInfoAtPosition for hover hints
proxy.getQuickInfoAtPosition = (fileName, position) => {
const original = info.languageService.getQuickInfoAtPosition(fileName, position);
try {
const program = info.languageService.getProgram();
if (!program)
return original;
const sourceFile = program.getSourceFile(fileName);
if (!sourceFile)
return original;
const typeChecker = program.getTypeChecker();
const text = sourceFile.text;
// Check if we're near a tRPC call
const wordRange = text.substring(Math.max(0, position - 50), Math.min(text.length, position + 50));
// Check if we're in a pattern like: variable.path.path.method()
if (!wordRange.match(/\b\w+\s*\.\s*[\w.]+/)) {
return original;
}
// Extract variable name
const varMatch = wordRange.match(/(\w+)\s*\.\s*[\w.]+/);
if (!varMatch)
return original;
const variableName = varMatch[1];
const utilsVariables = findUseUtilsVariables(sourceFile);
if (!typeResolver.isTrpcClient(variableName, sourceFile, position, typeChecker) &&
!utilsVariables.has(variableName)) {
return original;
}
// Add navigation hint
if (original?.displayParts) {
original.displayParts.push({ text: '\n', kind: 'lineBreak' }, { text: '[TRPC-Nav] ', kind: 'punctuation' }, {
text: 'Cmd+Click to navigate to procedure definition',
kind: 'text',
});
}
return original;
}
catch (error) {
logger.error('Error in getQuickInfoAtPosition', error);
return original;
}
};
return proxy;
}
function init(_modules) {
return { create };
}
module.exports = init;
//# sourceMappingURL=index.js.map