diff --git a/package.json b/package.json index e617bfe5..0570abfc 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "Other" ], "activationEvents": [ - "onLanguage:php" + "onLanguage:php", + "onLanguage:typescript", + "onLanguage:typescriptreact" ], "main": "./dist/extension.js", "grammars": [ diff --git a/src/extension.ts b/src/extension.ts index a8f536bf..603fc1a5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -29,6 +29,7 @@ import { wrapSelectionCommand, wrapWithHelperCommands, } from "./commands/wrapWithHelper"; +import { WayfinderHoverProvider } from "./features/wayfinder"; import { configAffected } from "./support/config"; import { collectDebugInfo } from "./support/debug"; import { @@ -215,6 +216,10 @@ export async function activate(context: vscode.ExtensionContext) { ...hoverProviders.map((provider) => vscode.languages.registerHoverProvider(LANGUAGES, provider), ), + vscode.languages.registerHoverProvider( + ["typescript", "typescriptreact"], + new WayfinderHoverProvider(), + ), // ...testRunnerCommands, // testController, vscode.languages.registerCodeActionsProvider( diff --git a/src/features/wayfinder.ts b/src/features/wayfinder.ts new file mode 100644 index 00000000..cd77b7bd --- /dev/null +++ b/src/features/wayfinder.ts @@ -0,0 +1,107 @@ +import { HoverActions } from "@src/support/hoverAction"; +import { projectPath } from "@src/support/project"; +import { detect, languageService } from "@src/tsParser/tsParser"; +import { TsParsingResult } from "@src/types"; +import * as vscode from "vscode"; + +const isInHoverRange = ( + context: TsParsingResult.ContextValue, + offset: number, +): boolean => offset >= context.start && offset <= context.end; + +const findContextAtOffset = ( + contexts: TsParsingResult.ContextValue[], + offset: number, +): TsParsingResult.ContextValue | undefined => { + for (const child of contexts) { + if (!isInHoverRange(child, offset)) { + continue; + } + + return findContextAtOffset(child.children, offset) ?? child; + } + + return undefined; +}; + +export class WayfinderHoverProvider implements vscode.HoverProvider { + provideHover( + document: vscode.TextDocument, + position: vscode.Position, + ): vscode.ProviderResult { + const range = document.getWordRangeAtPosition(position); + + if (!range) { + return null; + } + + const contexts = detect(document); + + if (!contexts) { + return null; + } + + const foundContext = findContextAtOffset( + contexts, + document.offsetAt(position), + ); + + if (foundContext?.type !== "methodCall") { + return null; + } + + if ( + !foundContext.returnTypes.some( + (type) => + ["RouteDefinition", "RouteFormDefinition"].some((name) => + type.name.startsWith(name), + ) && type.importPath?.endsWith("/wayfinder/index.ts"), + ) + ) { + return null; + } + + const quickInfo = languageService.getQuickInfoAtPosition( + document.fileName, + foundContext.start, + ); + + const controllerTag = quickInfo?.tags?.find( + (tag) => + tag.name === "see" && + (tag.text?.some((text) => + /Controller\.php:\d+$/.test(text.text), + ) || + false), + ); + + if (!controllerTag) { + return null; + } + + const [controllerPath, lineAsString] = controllerTag.text + ?.map((text) => text.text.trim()) + .join("") + .split(":") || [undefined, undefined]; + + if (!controllerPath) { + return null; + } + + const line = lineAsString ? Number(lineAsString) : 0; + + const hoverActions = new HoverActions([ + { + title: "Go to controller", + command: "laravel.open", + arguments: [ + vscode.Uri.file(projectPath(controllerPath)), + line, + 0, + ], + }, + ]); + + return new vscode.Hover(hoverActions.getAsMarkdownString(), range); + } +} diff --git a/src/support/hoverAction.ts b/src/support/hoverAction.ts new file mode 100644 index 00000000..ffded5f1 --- /dev/null +++ b/src/support/hoverAction.ts @@ -0,0 +1,32 @@ +import * as vscode from "vscode"; + +export class HoverActions { + private readonly commands: vscode.Command[]; + + constructor(commands: vscode.Command[] = []) { + this.commands = commands; + } + + public push(command: vscode.Command): this { + this.commands.push(command); + + return this; + } + + public getAsMarkdownString(): vscode.MarkdownString { + const string = this.commands + .map((command) => { + return [ + `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify(command.arguments))})`, + `${" ".repeat(3)}`, + ].join(""); + }) + .join(""); + + const markdownString = new vscode.MarkdownString(string); + markdownString.supportHtml = true; + markdownString.isTrusted = true; + + return markdownString; + } +} diff --git a/src/tsParser/parsers/abstractParser.ts b/src/tsParser/parsers/abstractParser.ts new file mode 100644 index 00000000..d3100567 --- /dev/null +++ b/src/tsParser/parsers/abstractParser.ts @@ -0,0 +1,24 @@ +import { TsParsingResult } from "@src/types"; +import ts from "typescript"; + +export abstract class NodeParser { + abstract parse(node: ts.Node): TsParsingResult.ContextValue; + + protected getSymbolFlagValues(flags: ts.SymbolFlags): number[] { + const values: number[] = []; + + for (const key in ts.SymbolFlags) { + const value = (ts.SymbolFlags as any)[key]; + + if ( + typeof value === "number" && + (flags & value) === value && + value !== 0 + ) { + values.push(value); + } + } + + return values; + } +} diff --git a/src/tsParser/parsers/aliasExcludesParser.ts b/src/tsParser/parsers/aliasExcludesParser.ts new file mode 100644 index 00000000..6af820e8 --- /dev/null +++ b/src/tsParser/parsers/aliasExcludesParser.ts @@ -0,0 +1,20 @@ +import { TsParsingResult } from "@src/types"; +import ts from "typescript"; +import { NodeParser } from "./abstractParser"; +import { MethodCallParser } from "./methodCallParser"; + +export class AliasExcludesParser extends NodeParser { + private methodCallParser: MethodCallParser; + + constructor(methodCallParser: MethodCallParser) { + super(); + + this.methodCallParser = methodCallParser; + } + + public parse( + node: ts.Node, + ): TsParsingResult.MethodCall | TsParsingResult.Base { + return this.methodCallParser.parse(node); + } +} diff --git a/src/tsParser/parsers/assignmentParser.ts b/src/tsParser/parsers/assignmentParser.ts new file mode 100644 index 00000000..c2cbbbef --- /dev/null +++ b/src/tsParser/parsers/assignmentParser.ts @@ -0,0 +1,20 @@ +import { TsParsingResult } from "@src/types"; +import ts from "typescript"; +import { NodeParser } from "./abstractParser"; +import { MethodCallParser } from "./methodCallParser"; + +export class AssignmentParser extends NodeParser { + private methodCallParser: MethodCallParser; + + constructor(methodCallParser: MethodCallParser) { + super(); + + this.methodCallParser = methodCallParser; + } + + public parse( + node: ts.Node, + ): TsParsingResult.MethodCall | TsParsingResult.Base { + return this.methodCallParser.parse(node); + } +} diff --git a/src/tsParser/parsers/baseParser.ts b/src/tsParser/parsers/baseParser.ts new file mode 100644 index 00000000..dadecef6 --- /dev/null +++ b/src/tsParser/parsers/baseParser.ts @@ -0,0 +1,38 @@ +import { TsParsingResult } from "@src/types"; +import ts from "typescript"; +import { NodeParserFactory } from "."; +import { NodeParser } from "./abstractParser"; + +export class BaseParser extends NodeParser { + protected nodeParserFactory: NodeParserFactory; + + protected typeChecker: ts.TypeChecker; + + constructor( + nodeParserFactory: NodeParserFactory, + typeChecker: ts.TypeChecker, + ) { + super(); + + this.nodeParserFactory = nodeParserFactory; + this.typeChecker = typeChecker; + } + + public parse(node: ts.Node): TsParsingResult.Base { + const children = this.nodeParserFactory.createContexts( + node.getChildren(), + ); + + const symbol = this.typeChecker.getSymbolAtLocation(node); + + return { + type: "base", + symbolFlags: symbol?.getFlags() + ? this.getSymbolFlagValues(symbol.getFlags()) + : [], + children: children, + start: node.getStart(), + end: node.getEnd(), + } as TsParsingResult.Base; + } +} diff --git a/src/tsParser/parsers/index.ts b/src/tsParser/parsers/index.ts new file mode 100644 index 00000000..d0e9134a --- /dev/null +++ b/src/tsParser/parsers/index.ts @@ -0,0 +1,53 @@ +import { TsParsingResult } from "@src/types"; +import ts from "typescript"; +import { NodeParser } from "./abstractParser"; +import { AliasExcludesParser } from "./aliasExcludesParser"; +import { AssignmentParser } from "./assignmentParser"; +import { BaseParser } from "./baseParser"; +import { MethodCallParser } from "./methodCallParser"; +import { PropertyParser } from "./propertyParser"; + +export class NodeParserFactory { + private typeChecker: ts.TypeChecker; + + constructor(typeChecker: ts.TypeChecker) { + this.typeChecker = typeChecker; + } + + public createContexts( + nodes: readonly ts.Node[], + ): TsParsingResult.ContextValue[] { + return nodes.map((node) => { + const nodeParser = this.createNodeParser(node); + + return nodeParser.parse(node); + }); + } + + private createNodeParser(node: ts.Node): NodeParser { + const symbol = this.typeChecker.getSymbolAtLocation(node); + + const flags = symbol?.getFlags(); + + if (flags) { + const methodCallParser = new MethodCallParser( + this, + this.typeChecker, + ); + + if (flags & ts.SymbolFlags.AliasExcludes) { + return new AliasExcludesParser(methodCallParser); + } + + if (flags & ts.SymbolFlags.Property) { + return new PropertyParser(methodCallParser); + } + + if (flags & ts.SymbolFlags.Assignment) { + return new AssignmentParser(methodCallParser); + } + } + + return new BaseParser(this, this.typeChecker); + } +} diff --git a/src/tsParser/parsers/methodCallParser.ts b/src/tsParser/parsers/methodCallParser.ts new file mode 100644 index 00000000..c6a37a02 --- /dev/null +++ b/src/tsParser/parsers/methodCallParser.ts @@ -0,0 +1,71 @@ +import { TsParsingResult } from "@src/types"; +import ts from "typescript"; +import { NodeParserFactory } from "."; +import { NodeParser } from "./abstractParser"; +import { BaseParser } from "./baseParser"; + +export class MethodCallParser extends NodeParser { + protected nodeParserFactory: NodeParserFactory; + + protected typeChecker: ts.TypeChecker; + + constructor( + nodeParserFactory: NodeParserFactory, + typeChecker: ts.TypeChecker, + ) { + super(); + + this.nodeParserFactory = nodeParserFactory; + this.typeChecker = typeChecker; + } + + public parse( + node: ts.Node, + ): TsParsingResult.MethodCall | TsParsingResult.Base { + const children = this.nodeParserFactory.createContexts( + node.getChildren(), + ); + + const symbol = this.typeChecker.getSymbolAtLocation(node); + + if (!symbol) { + return new BaseParser( + this.nodeParserFactory, + this.typeChecker, + ).parse(node); + } + + const type = this.typeChecker.getTypeOfSymbolAtLocation(symbol, node); + + const signatures = type.getCallSignatures(); + + const returnTypes = signatures.map((sig) => { + const type = this.typeChecker.getReturnTypeOfSignature(sig); + + let importPath = null; + + const typeSymbol = type.aliasSymbol ?? type.getSymbol(); + + if (typeSymbol) { + const declaration = typeSymbol.getDeclarations()?.[0]; + + importPath = declaration?.getSourceFile().fileName ?? null; + } + + return { + name: this.typeChecker.typeToString(type), + importPath: importPath, + } as TsParsingResult.ReturnType; + }); + + return { + type: "methodCall", + symbolFlags: this.getSymbolFlagValues(symbol.getFlags()), + methodName: symbol.getName(), + children: children, + returnTypes: returnTypes, + start: node.getStart(), + end: node.getEnd(), + } as TsParsingResult.MethodCall; + } +} diff --git a/src/tsParser/parsers/propertyParser.ts b/src/tsParser/parsers/propertyParser.ts new file mode 100644 index 00000000..a9696df3 --- /dev/null +++ b/src/tsParser/parsers/propertyParser.ts @@ -0,0 +1,20 @@ +import { TsParsingResult } from "@src/types"; +import ts from "typescript"; +import { NodeParser } from "./abstractParser"; +import { MethodCallParser } from "./methodCallParser"; + +export class PropertyParser extends NodeParser { + private methodCallParser: MethodCallParser; + + constructor(methodCallParser: MethodCallParser) { + super(); + + this.methodCallParser = methodCallParser; + } + + public parse( + node: ts.Node, + ): TsParsingResult.MethodCall | TsParsingResult.Base { + return this.methodCallParser.parse(node); + } +} diff --git a/src/tsParser/tsParser.ts b/src/tsParser/tsParser.ts new file mode 100644 index 00000000..65278247 --- /dev/null +++ b/src/tsParser/tsParser.ts @@ -0,0 +1,102 @@ +import { TsParsingResult } from "@src/types"; +import fs from "fs"; +import path from "path"; +import ts from "typescript"; +import * as vscode from "vscode"; +import { Cache } from "../support/cache"; +import { getWorkspaceFolders } from "../support/project"; +import { NodeParserFactory } from "./parsers"; + +const detected = new Cache(50); +const files = new Map(); + +class LanguageServiceFactory { + public static createLanguageService(workspacePath: string) { + const servicesHost: ts.LanguageServiceHost = { + getScriptFileNames: () => Array.from(files.keys()), + getScriptVersion: (fileName) => + files.get(fileName)?.version.toString() ?? "0", + getScriptSnapshot: (fileName) => { + if (!fs.existsSync(fileName)) { + return undefined; + } + + return ts.ScriptSnapshot.fromString( + fs.readFileSync(fileName, "utf8"), + ); + }, + getCurrentDirectory: () => workspacePath, + getCompilationSettings: () => { + const configPath = ts.findConfigFile( + workspacePath, + ts.sys.fileExists, + "tsconfig.json", + ); + + if (!configPath) { + return {}; + } + + const configFile = ts.readConfigFile( + configPath, + ts.sys.readFile, + ); + + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(configPath), + ); + + return parsed.options; + }, + getDefaultLibFileName: ts.getDefaultLibFilePath, + fileExists: ts.sys.fileExists, + readFile: ts.sys.readFile, + readDirectory: ts.sys.readDirectory, + }; + + return ts.createLanguageService( + servicesHost, + ts.createDocumentRegistry(), + ); + } +} + +export const languageService = LanguageServiceFactory.createLanguageService( + getWorkspaceFolders()[0].uri.fsPath, +); + +export const detect = ( + doc: vscode.TextDocument, +): TsParsingResult.ContextValue[] | undefined => { + const code = doc.getText(); + + if (detected.has(code)) { + return detected.get(code) as TsParsingResult.ContextValue[]; + } + + files.set(doc.fileName, { + version: (files.get(doc.fileName)?.version ?? 0) + 1, + }); + + const program = languageService.getProgram(); + + if (!program) { + return undefined; + } + + const sourceFile = program.getSourceFile(doc.fileName); + + if (!sourceFile) { + return undefined; + } + + const nodeParserFactory = new NodeParserFactory(program.getTypeChecker()); + + const contexts = nodeParserFactory.createContexts(sourceFile.getChildren()); + + detected.set(code, contexts); + + return contexts; +}; diff --git a/src/types.ts b/src/types.ts index c3e8052f..69fe0506 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,3 +145,30 @@ export namespace AutocompleteParsingResult { }; } } + +export namespace TsParsingResult { + export type ContextValue = MethodCall | Base; + + export interface Base { + type: "base"; + symbolFlags: number[]; + children: ContextValue[]; + start: number; + end: number; + } + + export interface MethodCall { + type: "methodCall"; + symbolFlags: number[]; + methodName: string; + children: ContextValue[]; + returnTypes: ReturnType[]; + start: number; + end: number; + } + + export interface ReturnType { + name: string; + importPath: string | null; + } +}