diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..016fc7d --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,14 @@ +name: lint +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '22' + - run: npm install + - run: npm run lint \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..8fdd954 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/package.json b/package.json index 41d8783..671647f 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "magento-toolbox", - "displayName": "magento-toolbox", - "description": "", - "version": "0.0.1", + "displayName": "Magento Toolbox", + "description": "Magento 2 code generation, inspection and utility tools", + "publisher": "magebit", + "version": "1.0.0", "engines": { "vscode": "^1.93.1" }, @@ -16,6 +17,16 @@ ], "main": "./dist/extension.js", "contributes": { + "configuration": { + "title": "Magento Toolbox", + "properties": { + "magento-toolbox.magentoCliPath": { + "type": "string", + "default": "bin/magento", + "description": "Path to Magento CLI tool. Relative to workspace root or absolute path." + } + } + }, "submenus": [ { "id": "magento-toolbox.submenu", @@ -38,6 +49,18 @@ { "command": "magento-toolbox.generateContextPlugin", "title": "Magento Toolbox: Generate Plugin" + }, + { + "command": "magento-toolbox.copyMagentoPath", + "title": "Magento Toolbox: Copy Magento Path" + }, + { + "command": "magento-toolbox.generateXmlCatalog", + "title": "Magento Toolbox: Generate XML URN Catalog" + }, + { + "command": "magento-toolbox.generateObserver", + "title": "Magento Toolbox: Generate Observer" } ], "menus": { @@ -45,6 +68,10 @@ { "command": "magento-toolbox.generateContextPlugin", "when": "false" + }, + { + "command": "magento-toolbox.copyMagentoPath", + "when": "false" } ], "editor/context": [ @@ -64,6 +91,13 @@ "command": "magento-toolbox.generateContextPlugin", "when": "magento-toolbox.canGeneratePlugin" } + ], + "magento-toolbox.explorer-submenu": [ + { + "command": "magento-toolbox.copyMagentoPath", + "title": "Magento Toolbox: Copy Magento Path", + "when": "resourceExtname in magento-toolbox.supportedMagentoPathExtensions && resourcePath =~ /view/" + } ] } }, diff --git a/resources/icons/observer.svg b/resources/icons/observer.svg new file mode 100644 index 0000000..4c56d07 --- /dev/null +++ b/resources/icons/observer.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/codelens/ObserverCodelensProvider.ts b/src/codelens/ObserverCodelensProvider.ts new file mode 100644 index 0000000..50261a5 --- /dev/null +++ b/src/codelens/ObserverCodelensProvider.ts @@ -0,0 +1,72 @@ +import ObserverClassInfo from 'common/php/ObserverClassInfo'; +import PhpDocumentParser from 'common/php/PhpDocumentParser'; +import EventsIndexer from 'indexer/events/EventsIndexer'; +import IndexManager from 'indexer/IndexManager'; +import { NodeKind } from 'parser/php/Parser'; +import { Call } from 'php-parser'; +import Position from 'util/Position'; +import { CodeLens, CodeLensProvider, TextDocument } from 'vscode'; + +export default class ObserverCodelensProvider implements CodeLensProvider { + public async provideCodeLenses(document: TextDocument): Promise { + const codelenses: CodeLens[] = []; + + const phpFile = await PhpDocumentParser.parse(document); + + const observerClassInfo = new ObserverClassInfo(phpFile); + + const eventDispatchCalls = observerClassInfo.getEventDispatchCalls(); + + if (eventDispatchCalls.length === 0) { + return codelenses; + } + + codelenses.push(...this.getEventDispatchCodeLenses(eventDispatchCalls)); + + return codelenses; + } + + private getEventDispatchCodeLenses(eventDispatchCalls: Call[]): CodeLens[] { + const eventsIndexData = IndexManager.getIndexData(EventsIndexer.KEY); + + if (!eventsIndexData) { + return []; + } + + const codelenses: CodeLens[] = []; + + for (const eventDispatchCall of eventDispatchCalls) { + const args = eventDispatchCall.arguments; + + if (args.length === 0) { + continue; + } + + const firstArg = args[0]; + + if (!firstArg || firstArg.kind !== NodeKind.String || !firstArg.loc) { + continue; + } + + const eventName = (firstArg as any).value; + + const event = eventsIndexData.getEventByName(eventName); + + if (!event) { + continue; + } + + const range = Position.phpAstLocationToVsCodeRange(firstArg.loc); + + const codelens = new CodeLens(range, { + title: 'Create an Observer', + command: 'magento-toolbox.generateObserver', + arguments: [event.name], + }); + + codelenses.push(codelens); + } + + return codelenses; + } +} diff --git a/src/command/CopyMagentoPathCommand.ts b/src/command/CopyMagentoPathCommand.ts new file mode 100644 index 0000000..6a1cf9b --- /dev/null +++ b/src/command/CopyMagentoPathCommand.ts @@ -0,0 +1,82 @@ +import { Command } from 'command/Command'; +import IndexManager from 'indexer/IndexManager'; +import ModuleIndexer from 'indexer/module/ModuleIndexer'; +import { Uri, window, env } from 'vscode'; + +export default class CopyMagentoPathCommand extends Command { + public static readonly TEMPLATE_EXTENSIONS = ['.phtml']; + public static readonly WEB_EXTENSIONS = ['.css', '.js']; + public static readonly IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg']; + + private static readonly TEMPLATE_PATHS = [ + 'view/frontend/templates/', + 'view/adminhtml/templates/', + 'view/base/templates/', + 'templates/', + ]; + + private static readonly WEB_PATHS = [ + 'view/frontend/web/', + 'view/adminhtml/web/', + 'view/base/web/', + 'web/', + ]; + + constructor() { + super('magento-toolbox.copyMagentoPath'); + } + + public async execute(file: Uri): Promise { + if (!file) { + return; + } + + const moduleIndexData = IndexManager.getIndexData(ModuleIndexer.KEY); + + if (!moduleIndexData) { + return; + } + + const module = moduleIndexData.getModuleByUri(file); + + if (!module) { + return; + } + + const paths = this.getPaths(file); + + if (!paths) { + return; + } + + const pathIndex = paths.findIndex(p => file.fsPath.lastIndexOf(p) !== -1); + + if (pathIndex === -1) { + return; + } + + const endIndex = file.fsPath.lastIndexOf(paths[pathIndex]); + const offset = paths[pathIndex].length; + const relativePath = file.fsPath.slice(offset + endIndex); + + const magentoPath = `${module.name}::${relativePath}`; + + await env.clipboard.writeText(magentoPath); + window.showInformationMessage(`Copied: ${magentoPath}`); + } + + private getPaths(file: Uri): string[] | undefined { + if (CopyMagentoPathCommand.TEMPLATE_EXTENSIONS.some(ext => file.fsPath.endsWith(ext))) { + return CopyMagentoPathCommand.TEMPLATE_PATHS; + } + + if ( + CopyMagentoPathCommand.WEB_EXTENSIONS.some(ext => file.fsPath.endsWith(ext)) || + CopyMagentoPathCommand.IMAGE_EXTENSIONS.some(ext => file.fsPath.endsWith(ext)) + ) { + return CopyMagentoPathCommand.WEB_PATHS; + } + + return undefined; + } +} diff --git a/src/command/GenerateContextPluginCommand.ts b/src/command/GenerateContextPluginCommand.ts index 16cf152..afb137f 100644 --- a/src/command/GenerateContextPluginCommand.ts +++ b/src/command/GenerateContextPluginCommand.ts @@ -6,6 +6,7 @@ import PluginClassGenerator from 'generator/plugin/PluginClassGenerator'; import PluginDiGenerator from 'generator/plugin/PluginDiGenerator'; import PhpParser from 'parser/php/Parser'; import { PhpMethod } from 'parser/php/PhpMethod'; +import Common from 'util/Common'; import * as vscode from 'vscode'; import PluginContextWizard from 'wizard/PluginContextWizard'; @@ -22,6 +23,13 @@ export default class GenerateContextPluginCommand extends Command { return; } + const workspaceFolder = Common.getActiveWorkspaceFolder(); + + if (!workspaceFolder) { + vscode.window.showErrorMessage('No active workspace folder'); + return; + } + const selection = editor?.selection; if (!editor || !selection) { vscode.window.showErrorMessage('No selection'); @@ -65,8 +73,6 @@ export default class GenerateContextPluginCommand extends Command { new PluginDiGenerator(data, classlike, method), ]); - const workspaceFolder = vscode.workspace.workspaceFolders![0]; - await manager.generate(workspaceFolder.uri); await manager.writeFiles(); await manager.refreshIndex(workspaceFolder); diff --git a/src/command/GenerateModuleCommand.ts b/src/command/GenerateModuleCommand.ts index b974df1..91db96c 100644 --- a/src/command/GenerateModuleCommand.ts +++ b/src/command/GenerateModuleCommand.ts @@ -3,9 +3,11 @@ import ModuleXmlGenerator from 'generator/module/ModuleXmlGenerator'; import ModuleRegistrationGenerator from 'generator/module/ModuleRegistrationGenerator'; import ModuleComposerGenerator from 'generator/module/ModuleComposerGenerator'; import ModuleLicenseGenerator from 'generator/module/ModuleLicenseGenerator'; -import ModuleWizard from 'wizard/ModuleWizard'; +import ModuleWizard, { ModuleWizardData, ModuleWizardComposerData } from 'wizard/ModuleWizard'; import FileGeneratorManager from 'generator/FileGeneratorManager'; -import { workspace } from 'vscode'; +import { window } from 'vscode'; +import Common from 'util/Common'; +import WizzardClosedError from 'webview/error/WizzardClosedError'; export default class GenerateModuleCommand extends Command { constructor() { @@ -15,7 +17,17 @@ export default class GenerateModuleCommand extends Command { public async execute(...args: any[]): Promise { const moduleWizard = new ModuleWizard(); - const data = await moduleWizard.show(); + let data: ModuleWizardData | ModuleWizardComposerData; + + try { + data = await moduleWizard.show(); + } catch (error) { + if (error instanceof WizzardClosedError) { + return; + } + + throw error; + } const manager = new FileGeneratorManager([ new ModuleXmlGenerator(data), @@ -30,7 +42,12 @@ export default class GenerateModuleCommand extends Command { manager.addGenerator(new ModuleLicenseGenerator(data)); } - const workspaceFolder = workspace.workspaceFolders![0]; + const workspaceFolder = Common.getActiveWorkspaceFolder(); + + if (!workspaceFolder) { + window.showErrorMessage('No active workspace folder'); + return; + } await manager.generate(workspaceFolder.uri); await manager.writeFiles(); diff --git a/src/command/GenerateObserverCommand.ts b/src/command/GenerateObserverCommand.ts new file mode 100644 index 0000000..bcfd183 --- /dev/null +++ b/src/command/GenerateObserverCommand.ts @@ -0,0 +1,47 @@ +import { Command } from 'command/Command'; +import ObserverWizard, { ObserverWizardData } from 'wizard/ObserverWizard'; +import WizzardClosedError from 'webview/error/WizzardClosedError'; +import FileGeneratorManager from 'generator/FileGeneratorManager'; +import Common from 'util/Common'; +import { window } from 'vscode'; +import ObserverClassGenerator from 'generator/observer/ObserverClassGenerator'; +import ObserverEventsGenerator from 'generator/observer/ObserverEventsGenerator'; + +export default class GenerateObserverCommand extends Command { + constructor() { + super('magento-toolbox.generateObserver'); + } + + public async execute(eventName?: string): Promise { + const observerWizard = new ObserverWizard(); + + let data: ObserverWizardData; + + try { + data = await observerWizard.show(eventName); + } catch (error) { + if (error instanceof WizzardClosedError) { + return; + } + + throw error; + } + + const manager = new FileGeneratorManager([ + new ObserverClassGenerator(data), + new ObserverEventsGenerator(data), + ]); + + const workspaceFolder = Common.getActiveWorkspaceFolder(); + + if (!workspaceFolder) { + window.showErrorMessage('No active workspace folder'); + return; + } + + await manager.generate(workspaceFolder.uri); + await manager.writeFiles(); + await manager.refreshIndex(workspaceFolder); + manager.openFirstFile(); + } +} diff --git a/src/command/GenerateXmlCatalogCommand.ts b/src/command/GenerateXmlCatalogCommand.ts new file mode 100644 index 0000000..db7ebb4 --- /dev/null +++ b/src/command/GenerateXmlCatalogCommand.ts @@ -0,0 +1,130 @@ +import { Command } from 'command/Command'; +import MagentoCli from 'common/MagentoCli'; +import Common from 'util/Common'; +import { ConfigurationTarget, extensions, Uri, window, workspace, WorkspaceFolder } from 'vscode'; +import FileSystem from 'util/FileSystem'; +import get from 'lodash-es/get'; +import { XMLParser } from 'fast-xml-parser'; +import XmlGenerator from 'generator/XmlGenerator'; + +export default class GenerateXmlCatalogCommand extends Command { + private static readonly XML_EXTENSION = 'redhat.vscode-xml'; + + constructor() { + super('magento-toolbox.generateXmlCatalog'); + } + + public async execute(...args: any[]): Promise { + if (!this.checkExtension()) { + return; + } + + const workspaceFolder = Common.getActiveWorkspaceFolder(); + + if (!workspaceFolder) { + window.showErrorMessage('No active workspace folder'); + return; + } + + const catalogLocation = Uri.joinPath(workspaceFolder.uri, '.vscode/magento-catalog.xml'); + + if (!(await FileSystem.fileExists(catalogLocation))) { + const success = await this.generateCatalog(workspaceFolder); + + if (!success) { + return; + } + } + + await this.formatAndWriteCatalog(catalogLocation, workspaceFolder.uri); + await this.updateXmlConfig(workspaceFolder, catalogLocation); + + window.showInformationMessage('XML URN catalog generated and configured successfully'); + } + + private async formatAndWriteCatalog(catalogLocation: Uri, workspaceUri: Uri) { + const catalogXmlString = await FileSystem.readFile(catalogLocation); + const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name, jpath) => { + return jpath === 'project.component.resource'; + }, + }); + const catalogXml = xmlParser.parse(catalogXmlString); + + const xmlCatalog: any = { + catalog: { + '@_xmlns': 'urn:oasis:names:tc:entity:xmlns:xml:catalog', + system: [], + }, + }; + + const components = get(catalogXml, 'project.component', []); + + for (const component of components) { + if (!component.resource) { + continue; + } + + for (const resource of component.resource) { + let location = resource['@_location']; + location = location.replace('$PROJECT_DIR$', workspaceUri.fsPath); + xmlCatalog.catalog.system.push({ + '@_systemId': resource['@_url'], + '@_uri': location, + }); + } + } + + const xmlGenerator = new XmlGenerator(xmlCatalog); + const formattedCatalog = xmlGenerator.toString(); + + await FileSystem.writeFile(catalogLocation, formattedCatalog); + } + + private async generateCatalog(workspaceFolder: WorkspaceFolder): Promise { + const catalogLocation = Uri.joinPath(workspaceFolder.uri, '.vscode/magento-catalog.xml'); + + const magentoCli = new MagentoCli(); + + try { + await magentoCli.run('dev:urn-catalog:generate', [catalogLocation.fsPath]); + } catch (error) { + console.error(error); + + window.showErrorMessage( + 'Failed to generate URN catalog. Try running this command manually: \n\n' + + `bin/magento dev:urn-catalog:generate ${catalogLocation.fsPath}` + ); + + return false; + } + + return true; + } + + private async updateXmlConfig(workspaceFolder: WorkspaceFolder, catalogLocation: Uri) { + const xmlConfig = workspace.getConfiguration('xml', workspaceFolder.uri); + + const catalogs = xmlConfig.get('catalogs', []); + + if (!catalogs.includes(catalogLocation.fsPath)) { + catalogs.push(catalogLocation.fsPath); + } + + await xmlConfig.update('catalogs', catalogs, ConfigurationTarget.Workspace); + } + + private checkExtension(): boolean { + if (!extensions.getExtension(GenerateXmlCatalogCommand.XML_EXTENSION)) { + window.showWarningMessage( + `This command requires ${GenerateXmlCatalogCommand.XML_EXTENSION} extension to be installed.` + ); + + return false; + } + + return true; + } +} diff --git a/src/common/Context.ts b/src/common/Context.ts index 6339fe4..be22b02 100644 --- a/src/common/Context.ts +++ b/src/common/Context.ts @@ -1,28 +1,29 @@ -import PhpParser from 'parser/php/Parser'; import { commands, TextEditor } from 'vscode'; -import { PhpFile } from 'parser/php/PhpFile'; -import DocumentCache from 'cache/DocumentCache'; +import PhpDocumentParser from './php/PhpDocumentParser'; +import CopyMagentoPathCommand from 'command/CopyMagentoPathCommand'; export interface EditorContext { canGeneratePlugin: boolean; + supportedMagentoPathExtensions: string[]; } class Context { - public editorContext: EditorContext = { - canGeneratePlugin: false, - }; + private editorContext: EditorContext; + + constructor() { + this.editorContext = this.getDefaultContext(); + } public async updateContext(type: 'editor' | 'selection', editor?: TextEditor) { if (!editor) { - await this.setContext({ - canGeneratePlugin: false, - }); + await this.setContext(this.getDefaultContext()); return; } if (type === 'editor') { await this.setContext({ + ...this.editorContext, canGeneratePlugin: await this.canGeneratePlugin(editor), }); } @@ -39,12 +40,23 @@ class Context { await Promise.all(promises); } + public getDefaultContext(): EditorContext { + return { + canGeneratePlugin: false, + supportedMagentoPathExtensions: [ + ...CopyMagentoPathCommand.TEMPLATE_EXTENSIONS, + ...CopyMagentoPathCommand.WEB_EXTENSIONS, + ...CopyMagentoPathCommand.IMAGE_EXTENSIONS, + ], + }; + } + private async canGeneratePlugin(editor: TextEditor): Promise { if (editor.document.languageId !== 'php') { return false; } - const phpFile = await this.getParsedPhpFile(editor); + const phpFile = await PhpDocumentParser.parse(editor.document); const phpClass = phpFile.classes[0]; if (phpClass) { @@ -71,19 +83,6 @@ class Context { return false; } - - private async getParsedPhpFile(editor: TextEditor): Promise { - const cacheKey = `php-file`; - - if (DocumentCache.has(editor.document, cacheKey)) { - return DocumentCache.get(editor.document, cacheKey); - } - - const phpParser = new PhpParser(); - const phpFile = await phpParser.parseDocument(editor.document); - DocumentCache.set(editor.document, cacheKey, phpFile); - return phpFile; - } } export default new Context(); diff --git a/src/common/IndexStorage.ts b/src/common/IndexStorage.ts deleted file mode 100644 index 5100ff8..0000000 --- a/src/common/IndexStorage.ts +++ /dev/null @@ -1,23 +0,0 @@ -export default class IndexStorage { - private static _indexStorage: Record = {}; - - public static set(key: T, value: IndexerData[T]) { - this._indexStorage[key] = value; - } - - public static get(key: T): IndexerData[T] | undefined { - return this._indexStorage[key]; - } - - public static clear() { - this._indexStorage = {}; - } - - public static async load() { - // TODO: Implement - } - - public static async save() { - // TODO: Implement - } -} diff --git a/src/common/MagentoCli.ts b/src/common/MagentoCli.ts new file mode 100644 index 0000000..583e051 --- /dev/null +++ b/src/common/MagentoCli.ts @@ -0,0 +1,48 @@ +import * as vscode from 'vscode'; + +export default class MagentoCli { + private static readonly TERMINAL_NAME = 'Magento Toolbox'; + private static DEFAULT_CLI_PATH = 'bin/magento'; + + private magentoCliPath: string; + + public constructor() { + this.magentoCliPath = + vscode.workspace.getConfiguration('magento-toolbox').get('magentoCliPath') || + MagentoCli.DEFAULT_CLI_PATH; + } + + public async run(command: string, args: string[] = []): Promise { + return new Promise((resolve, reject) => { + const cmd = `${this.magentoCliPath} ${command} ${args.join(' ')}`; + + const terminal = this.getOrCreateTerminal(); + let timeout: NodeJS.Timeout; + + terminal.show(); + terminal.sendText(cmd, true); + + vscode.window.onDidEndTerminalShellExecution(event => { + if (event.terminal.name === MagentoCli.TERMINAL_NAME) { + clearTimeout(timeout); + + resolve(event.exitCode ?? 0); + } + }); + + timeout = setTimeout(() => { + reject(new Error('Timeout')); + }, 30000); + }); + } + + private getOrCreateTerminal(): vscode.Terminal { + const terminal = vscode.window.terminals.find(t => t.name === MagentoCli.TERMINAL_NAME); + + if (terminal) { + return terminal; + } + + return vscode.window.createTerminal(MagentoCli.TERMINAL_NAME); + } +} diff --git a/src/common/Validation.ts b/src/common/Validation.ts new file mode 100644 index 0000000..d2e1c45 --- /dev/null +++ b/src/common/Validation.ts @@ -0,0 +1,37 @@ +export interface ValidationResult { + isValid: boolean; + errors?: string[]; +} + +export default class Validation { + public static readonly MODULE_NAME_REGEX = /^[A-Z][a-z0-9]*_[A-Z][a-z0-9]*$/; + public static readonly CLASS_NAME_REGEX = /^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/; + public static readonly SNAKE_CASE_REGEX = /^[a-z0-9_]+$/; + + public static isValidModuleName(name: string): ValidationResult { + if (!Validation.MODULE_NAME_REGEX.test(name)) { + return { + isValid: false, + errors: ['Module name must be in format "Vendor_Module"'], + }; + } + + return { isValid: true }; + } + + public static isValidClassName(name: string): ValidationResult { + if (!Validation.CLASS_NAME_REGEX.test(name)) { + return { isValid: false, errors: ['Class name must be in format "ClassName"'] }; + } + + return { isValid: true }; + } + + public static isSnakeCase(name: string): ValidationResult { + if (!Validation.SNAKE_CASE_REGEX.test(name)) { + return { isValid: false, errors: ['Name must be in snake_case format'] }; + } + + return { isValid: true }; + } +} diff --git a/src/common/php/ClasslikeInfo.ts b/src/common/php/ClasslikeInfo.ts index 82229ca..8779c6c 100644 --- a/src/common/php/ClasslikeInfo.ts +++ b/src/common/php/ClasslikeInfo.ts @@ -2,14 +2,18 @@ import { PhpClass } from 'parser/php/PhpClass'; import { PhpFile } from 'parser/php/PhpFile'; import { PhpInterface } from 'parser/php/PhpInterface'; import { PhpMethod } from 'parser/php/PhpMethod'; -import Position from 'util/Position'; -import { Range } from 'vscode'; +import PositionUtil from 'util/Position'; +import { MarkdownString, Range } from 'vscode'; export class ClasslikeInfo { - public constructor(private readonly phpFile: PhpFile) {} + public constructor(public readonly phpFile: PhpFile) {} + + public getDefinition(): PhpClass | PhpInterface { + return this.phpFile.classes[0] || this.phpFile.interfaces[0]; + } public getNameRange(): Range | undefined { - const classlikeNode = this.phpFile.classes[0] || this.phpFile.interfaces[0]; + const classlikeNode = this.getDefinition(); if (!classlikeNode) { return; @@ -21,18 +25,90 @@ export class ClasslikeInfo { return; } - const range = new Range( - Position.phpAstPositionToVsCodePosition(classlikeName.loc.start), - Position.phpAstPositionToVsCodePosition(classlikeName.loc.end) + return new Range( + PositionUtil.phpAstPositionToVsCodePosition(classlikeName.loc.start), + PositionUtil.phpAstPositionToVsCodePosition(classlikeName.loc.end) ); - - return range; } public getMethodByName( phpClasslike: PhpClass | PhpInterface, name: string ): PhpMethod | undefined { - return phpClasslike.methods.find(method => method.name === name); + return phpClasslike.methods?.find(method => method.name === name); + } + + public getNamespace(): string { + const classlikeNode = this.getDefinition(); + return `${this.phpFile.namespace}\\${classlikeNode.name}`; + } + + public getName(): string { + const classlikeNode = this.getDefinition(); + return classlikeNode.name; + } + + public isClass(): boolean { + return this.phpFile.classes.length > 0; + } + + public getExtends(): string[] { + const classlikeNode = this.getDefinition(); + + if (this.isClass()) { + return (classlikeNode as PhpClass).extends ? [(classlikeNode as PhpClass).extends!] : []; + } + + return (classlikeNode as PhpInterface).extends; + } + + public getImplements(): string[] { + const classlikeNode = this.getDefinition(); + + if (this.isClass()) { + return (classlikeNode as PhpClass).implements; + } + + return []; + } + + public getComments(): string[] { + const classlikeNode = this.getDefinition(); + + if (classlikeNode.ast.leadingComments && classlikeNode.ast.leadingComments.length > 0) { + return classlikeNode.ast.leadingComments.map(comment => comment.value); + } + + return []; + } + + public getHover(): MarkdownString { + const hover = new MarkdownString(); + hover.appendMarkdown(`**${this.getNamespace()}**\n\n`); + + let codeBlock = ' 0) { + codeBlock += this.getComments().join('\n') + '\n'; + } + + if (this.isClass()) { + codeBlock += `class `; + } else { + codeBlock += `interface `; + } + + codeBlock += this.getName(); + + if (this.getExtends().length > 0) { + codeBlock += ` extends ${this.getExtends().join(', ')}`; + } + + if (this.getImplements().length > 0) { + codeBlock += ` implements ${this.getImplements().join(', ')}`; + } + + hover.appendCodeblock(codeBlock, 'php'); + return hover; } } diff --git a/src/common/php/ObserverClassInfo.ts b/src/common/php/ObserverClassInfo.ts new file mode 100644 index 0000000..6af48c9 --- /dev/null +++ b/src/common/php/ObserverClassInfo.ts @@ -0,0 +1,100 @@ +import { EventsIndexData } from 'indexer/events/EventsIndexData'; +import EventsIndexer from 'indexer/events/EventsIndexer'; +import IndexManager from 'indexer/IndexManager'; +import { PhpClass } from 'parser/php/PhpClass'; +import { PhpFile } from 'parser/php/PhpFile'; +import { Event } from 'indexer/events/types'; +import { NodeKind } from 'parser/php/Parser'; +import { Call } from 'php-parser'; +import { Memoize } from 'typescript-memoize'; + +export default class ObserverClassInfo { + private eventsIndexData: EventsIndexData | undefined; + constructor(private readonly phpFile: PhpFile) {} + + public getObserverEvents(): Event[] { + const phpClass = this.getClass(); + + if (!phpClass) { + return []; + } + + const fqn = phpClass.namespace + '\\' + phpClass.name; + + if (!fqn) { + return []; + } + + const eventsIndexData = IndexManager.getIndexData(EventsIndexer.KEY); + + if (!eventsIndexData) { + return []; + } + + const events = eventsIndexData.findEventsByObserverInstance(fqn); + + return events; + } + + @Memoize() + public getEventDispatchCalls(): Call[] { + const eventsIndexData = IndexManager.getIndexData(EventsIndexer.KEY); + + if (!eventsIndexData) { + return []; + } + + const phpClass = this.getClass(); + + if (!phpClass) { + return []; + } + + const calls = phpClass.searchAst(NodeKind.Call); + const dispatchCalls = calls.filter((call: any) => { + if (call.what?.offset?.name === 'dispatch') { + return true; + } + + return false; + }); + + if (dispatchCalls.length === 0) { + return []; + } + + const eventNames = eventsIndexData.getEventNames(); + + const eventDispatchCalls = dispatchCalls.filter((call: Call) => { + if (call.arguments.length === 0) { + return false; + } + + const firstArgument = call.arguments[0]; + + if (!firstArgument || firstArgument.kind !== NodeKind.String) { + return false; + } + + const eventName = (firstArgument as any).value; + + if (eventNames.includes(eventName)) { + return true; + } + + return false; + }); + + return eventDispatchCalls; + } + + private getClass(): PhpClass | undefined { + const phpClass = this.phpFile.classes[0]; + + if (!phpClass) { + return undefined; + } + + return phpClass; + } +} diff --git a/src/common/php/PhpDocumentParser.ts b/src/common/php/PhpDocumentParser.ts new file mode 100644 index 0000000..a003ed5 --- /dev/null +++ b/src/common/php/PhpDocumentParser.ts @@ -0,0 +1,40 @@ +import DocumentCache from 'cache/DocumentCache'; +import PhpParser from 'parser/php/Parser'; +import { PhpFile } from 'parser/php/PhpFile'; +import { TextDocument, Uri } from 'vscode'; + +class PhpDocumentParser { + protected readonly parser: PhpParser; + + constructor() { + this.parser = new PhpParser(); + } + + public async parse(document: TextDocument): Promise { + const cacheKey = `php-file`; + + if (DocumentCache.has(document, cacheKey)) { + return DocumentCache.get(document, cacheKey); + } + + const phpParser = new PhpParser(); + const phpFile = await phpParser.parseDocument(document); + DocumentCache.set(document, cacheKey, phpFile); + return phpFile; + } + + public async parseUri(document: TextDocument, uri: Uri): Promise { + const cacheKey = `php-file-${uri.fsPath}`; + + if (DocumentCache.has(document, cacheKey)) { + return DocumentCache.get(document, cacheKey); + } + + const phpParser = new PhpParser(); + const phpFile = await phpParser.parse(uri); + DocumentCache.set(document, cacheKey, phpFile); + return phpFile; + } +} + +export default new PhpDocumentParser(); diff --git a/src/common/php/PluginSubjectInfo.ts b/src/common/php/PluginSubjectInfo.ts index 4270207..215e041 100644 --- a/src/common/php/PluginSubjectInfo.ts +++ b/src/common/php/PluginSubjectInfo.ts @@ -1,5 +1,6 @@ -import IndexStorage from 'common/IndexStorage'; -import DiIndexer from 'indexer/DiIndexer'; +import { DiIndexData } from 'indexer/di/DiIndexData'; +import DiIndexer from 'indexer/di/DiIndexer'; +import IndexManager from 'indexer/IndexManager'; import { PhpClass } from 'parser/php/PhpClass'; import { PhpFile } from 'parser/php/PhpFile'; import { PhpInterface } from 'parser/php/PhpInterface'; @@ -9,9 +10,9 @@ export default class PluginSubjectInfo { constructor(private readonly phpFile: PhpFile) {} public getPlugins(phpClasslike: PhpClass | PhpInterface) { - const diIndex = IndexStorage.get(DiIndexer.KEY); + const diIndexData = IndexManager.getIndexData(DiIndexer.KEY); - if (!diIndex) { + if (!diIndexData) { return []; } @@ -21,7 +22,7 @@ export default class PluginSubjectInfo { return []; } - const pluginClassData = diIndex.findPluginsForType(fqn); + const pluginClassData = diIndexData.findPluginsForType(fqn); return pluginClassData; } diff --git a/src/decorator/ObserverInstanceDecorationProvider.ts b/src/decorator/ObserverInstanceDecorationProvider.ts new file mode 100644 index 0000000..f534aa5 --- /dev/null +++ b/src/decorator/ObserverInstanceDecorationProvider.ts @@ -0,0 +1,141 @@ +import { DecorationOptions, TextEditorDecorationType, Uri, window } from 'vscode'; +import path from 'path'; +import TextDocumentDecorationProvider from './TextDocumentDecorationProvider'; +import { PhpClass } from 'parser/php/PhpClass'; +import { PhpInterface } from 'parser/php/PhpInterface'; +import ObserverClassInfo from 'common/php/ObserverClassInfo'; +import PhpDocumentParser from 'common/php/PhpDocumentParser'; +import { ClasslikeInfo } from 'common/php/ClasslikeInfo'; +import MarkdownMessageBuilder from 'common/MarkdownMessageBuilder'; +import Position from 'util/Position'; +import { NodeKind } from 'parser/php/Parser'; +import IndexManager from 'indexer/IndexManager'; +import EventsIndexer from 'indexer/events/EventsIndexer'; + +export default class ObserverInstanceDecorationProvider extends TextDocumentDecorationProvider { + public getType(): TextEditorDecorationType { + return window.createTextEditorDecorationType({ + gutterIconPath: path.join(__dirname, 'resources', 'icons', 'observer.svg'), + gutterIconSize: '80%', + borderColor: 'rgba(0, 188, 202, 0.5)', + borderStyle: 'dotted', + borderWidth: '0 0 1px 0', + }); + } + + public async getDecorations(): Promise { + const decorations: DecorationOptions[] = []; + const phpFile = await PhpDocumentParser.parse(this.document); + + const classLikeNode: PhpClass | PhpInterface | undefined = + phpFile.classes[0] || phpFile.interfaces[0]; + + if (!classLikeNode) { + return decorations; + } + + const observerClassInfo = new ObserverClassInfo(phpFile); + const classlikeInfo = new ClasslikeInfo(phpFile); + + if (this.isObserverInstance(classlikeInfo)) { + decorations.push(...this.getObserverInstanceDecorations(observerClassInfo, classlikeInfo)); + } + + decorations.push(...this.getEventDispatchDecorations(observerClassInfo)); + + return decorations; + } + + private getEventDispatchDecorations(observerClassInfo: ObserverClassInfo): DecorationOptions[] { + const decorations: DecorationOptions[] = []; + + const eventDispatchCalls = observerClassInfo.getEventDispatchCalls(); + + if (eventDispatchCalls.length === 0) { + return decorations; + } + + const eventsIndexData = IndexManager.getIndexData(EventsIndexer.KEY); + + if (!eventsIndexData) { + return decorations; + } + + for (const eventDispatchCall of eventDispatchCalls) { + const args = eventDispatchCall.arguments; + + if (args.length === 0) { + continue; + } + + const firstArg = args[0]; + + if (!firstArg || firstArg.kind !== NodeKind.String || !firstArg.loc) { + continue; + } + + const eventName = (firstArg as any).value; + + const event = eventsIndexData.getEventByName(eventName); + + if (!event) { + continue; + } + + const range = Position.phpAstLocationToVsCodeRange(firstArg.loc); + const hoverMessage = MarkdownMessageBuilder.create('Event observers'); + + for (const observer of event.observers) { + hoverMessage.appendMarkdown(`- [${observer.instance}](${Uri.file(event.diPath)})\n`); + } + + decorations.push({ + range, + hoverMessage: hoverMessage.build(), + }); + } + + return decorations; + } + + private getObserverInstanceDecorations( + observerClassInfo: ObserverClassInfo, + classlikeInfo: ClasslikeInfo + ): DecorationOptions[] { + const decorations: DecorationOptions[] = []; + + const observerEvents = observerClassInfo.getObserverEvents(); + + if (observerEvents.length === 0) { + return decorations; + } + + const nameRange = classlikeInfo.getNameRange(); + + if (!nameRange) { + return decorations; + } + + const hoverMessage = MarkdownMessageBuilder.create('Observer Events'); + + for (const event of observerEvents) { + hoverMessage.appendMarkdown(`- [${event.name}](${Uri.file(event.diPath)})\n`); + } + + decorations.push({ + range: nameRange, + hoverMessage: hoverMessage.build(), + }); + + return decorations; + } + + private isObserverInstance(classlikeInfo: ClasslikeInfo): boolean { + const imp = classlikeInfo.getImplements(); + + return ( + imp.includes('Magento\\Framework\\Event\\ObserverInterface') || + imp.includes('ObserverInterface') + ); + } +} diff --git a/src/decorator/PluginClassDecorationProvider.ts b/src/decorator/PluginClassDecorationProvider.ts index 0279dbf..f096186 100644 --- a/src/decorator/PluginClassDecorationProvider.ts +++ b/src/decorator/PluginClassDecorationProvider.ts @@ -1,10 +1,14 @@ -import { DecorationOptions, MarkdownString, TextEditorDecorationType, window, Range } from 'vscode'; +import { + DecorationOptions, + MarkdownString, + TextEditorDecorationType, + window, + Range, + Uri, +} from 'vscode'; import path from 'path'; -import { DiPlugin } from 'indexer/data/DiIndexData'; -import IndexStorage from 'common/IndexStorage'; import MarkdownMessageBuilder from 'common/MarkdownMessageBuilder'; import PhpNamespace from 'common/PhpNamespace'; -import AutoloadNamespaceIndexer from 'indexer/AutoloadNamespaceIndexer'; import TextDocumentDecorationProvider from './TextDocumentDecorationProvider'; import PhpParser from 'parser/php/Parser'; import Position from 'util/Position'; @@ -14,6 +18,12 @@ import Magento from 'util/Magento'; import { ClasslikeInfo } from 'common/php/ClasslikeInfo'; import { PhpClass } from 'parser/php/PhpClass'; import { PhpInterface } from 'parser/php/PhpInterface'; +import IndexManager from 'indexer/IndexManager'; +import AutoloadNamespaceIndexer from 'indexer/autoload-namespace/AutoloadNamespaceIndexer'; +import { AutoloadNamespaceIndexData } from 'indexer/autoload-namespace/AutoloadNamespaceIndexData'; +import { DiPlugin } from 'indexer/di/types'; +import PhpDocumentParser from 'common/php/PhpDocumentParser'; +import Common from 'util/Common'; export default class PluginClassDecorationProvider extends TextDocumentDecorationProvider { public getType(): TextEditorDecorationType { @@ -28,8 +38,7 @@ export default class PluginClassDecorationProvider extends TextDocumentDecoratio public async getDecorations(): Promise { const decorations: DecorationOptions[] = []; - const parser = new PhpParser(); - const phpFile = await parser.parseDocument(this.document); + const phpFile = await PhpDocumentParser.parse(this.document); const classLikeNode: PhpClass | PhpInterface | undefined = phpFile.classes[0] || phpFile.interfaces[0]; @@ -52,7 +61,13 @@ export default class PluginClassDecorationProvider extends TextDocumentDecoratio return decorations; } - const hoverMessage = await this.getInterceptorHoverMessage(classPlugins); + const namespaceIndexData = IndexManager.getIndexData(AutoloadNamespaceIndexer.KEY); + + if (!namespaceIndexData) { + return decorations; + } + + const hoverMessage = await this.getInterceptorHoverMessage(classPlugins, namespaceIndexData); decorations.push({ range: nameRange, @@ -60,7 +75,7 @@ export default class PluginClassDecorationProvider extends TextDocumentDecoratio }); const promises = classPlugins.map(async plugin => { - const fileUri = await IndexStorage.get(AutoloadNamespaceIndexer.KEY)!.findClassByNamespace( + const fileUri = await namespaceIndexData.findClassByNamespace( PhpNamespace.fromString(plugin.type) ); @@ -68,7 +83,7 @@ export default class PluginClassDecorationProvider extends TextDocumentDecoratio return; } - const pluginPhpFile = await parser.parse(fileUri); + const pluginPhpFile = await PhpDocumentParser.parseUri(this.document, fileUri); const pluginInfo = new PluginInfo(pluginPhpFile); const pluginMethods = pluginInfo.getPluginMethods(pluginPhpFile.classes[0]); @@ -79,7 +94,7 @@ export default class PluginClassDecorationProvider extends TextDocumentDecoratio return; } - const subjectMethod = classlikeInfo.getMethodByName(phpFile.classes[0], subjectMethodName); + const subjectMethod = classlikeInfo.getMethodByName(classLikeNode, subjectMethodName); if (!subjectMethod) { return; @@ -95,8 +110,8 @@ export default class PluginClassDecorationProvider extends TextDocumentDecoratio ); const message = MarkdownMessageBuilder.create('Interceptors'); - const link = `[${plugin.type}](${fileUri})`; - message.appendMarkdown(`- ${link} [(di.xml)](${plugin.diUri})\n`); + const link = `[${plugin.type}](${fileUri ? Uri.file(fileUri.fsPath) : '#'})`; + message.appendMarkdown(`- ${link} [(di.xml)](${Uri.file(plugin.diPath)})\n`); return { range, @@ -112,15 +127,20 @@ export default class PluginClassDecorationProvider extends TextDocumentDecoratio return decorations; } - private async getInterceptorHoverMessage(classInterceptors: DiPlugin[]): Promise { + private async getInterceptorHoverMessage( + classInterceptors: DiPlugin[], + namespaceIndexData: AutoloadNamespaceIndexData + ): Promise { const message = MarkdownMessageBuilder.create('Interceptors'); for (const interceptor of classInterceptors) { - const fileUri = await IndexStorage.get(AutoloadNamespaceIndexer.KEY)!.findClassByNamespace( + const fileUri = await namespaceIndexData.findClassByNamespace( PhpNamespace.fromString(interceptor.type) ); - message.appendMarkdown(`- [${interceptor.type}](${fileUri ?? '#'})\n`); + message.appendMarkdown( + `- [${interceptor.type}](${fileUri ? Uri.file(fileUri.fsPath) : '#'})\n` + ); } return message.build(); diff --git a/src/definition/XmlClasslikeDefinitionProvider.ts b/src/definition/XmlClasslikeDefinitionProvider.ts new file mode 100644 index 0000000..4cb9e31 --- /dev/null +++ b/src/definition/XmlClasslikeDefinitionProvider.ts @@ -0,0 +1,76 @@ +import { ClasslikeInfo } from 'common/php/ClasslikeInfo'; +import PhpDocumentParser from 'common/php/PhpDocumentParser'; +import PhpNamespace from 'common/PhpNamespace'; +import AutoloadNamespaceIndexer from 'indexer/autoload-namespace/AutoloadNamespaceIndexer'; +import IndexManager from 'indexer/IndexManager'; +import { + DefinitionProvider, + TextDocument, + Position, + CancellationToken, + LocationLink, + Uri, + Range, +} from 'vscode'; + +export class XmlClasslikeDefinitionProvider implements DefinitionProvider { + public async provideDefinition( + document: TextDocument, + position: Position, + token: CancellationToken + ) { + const range = document.getWordRangeAtPosition(position, /("[^"]+")|(>[^<]+<)/); + + if (!range) { + return null; + } + + const word = document.getText(range); + + const namespaceIndexData = IndexManager.getIndexData(AutoloadNamespaceIndexer.KEY); + + if (!namespaceIndexData) { + return null; + } + + const potentialNamespace = word.replace(/["<>]/g, ''); + + const classUri = await namespaceIndexData.findClassByNamespace( + PhpNamespace.fromString(potentialNamespace) + ); + + if (!classUri) { + return null; + } + + const targetPosition = await this.getClasslikeNameRange(document, classUri); + + const originSelectionRange = new Range( + range.start.with({ character: range.start.character + 1 }), + range.end.with({ character: range.end.character - 1 }) + ); + + return [ + { + targetUri: classUri, + targetRange: targetPosition, + originSelectionRange, + } as LocationLink, + ]; + } + + private async getClasslikeNameRange( + document: TextDocument, + classUri: Uri + ): Promise { + const phpFile = await PhpDocumentParser.parseUri(document, classUri); + const classLikeInfo = new ClasslikeInfo(phpFile); + const range = classLikeInfo.getNameRange(); + + if (!range) { + return new Position(0, 0); + } + + return range; + } +} diff --git a/src/extension.ts b/src/extension.ts index 91a0b59..e3581ab 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,18 +10,34 @@ import DiagnosticCollectionProvider from 'diagnostics/DiagnosticCollectionProvid import ChangeTextEditorSelectionObserver from 'observer/ChangeTextEditorSelectionObserver'; import DocumentCache from 'cache/DocumentCache'; import GenerateContextPluginCommand from 'command/GenerateContextPluginCommand'; +import { XmlClasslikeDefinitionProvider } from 'definition/XmlClasslikeDefinitionProvider'; +import CopyMagentoPathCommand from 'command/CopyMagentoPathCommand'; +import Common from 'util/Common'; +import GenerateXmlCatalogCommand from 'command/GenerateXmlCatalogCommand'; +import XmlClasslikeHoverProvider from 'hover/XmlClasslikeHoverProvider'; +import ObserverCodelensProvider from 'codelens/ObserverCodelensProvider'; +import GenerateObserverCommand from 'command/GenerateObserverCommand'; + // This method is called when your extension is activated // Your extension is activated the very first time the command is executed export async function activate(context: vscode.ExtensionContext) { console.log('[Magento Toolbox] Activating extension'); - const commands = [IndexWorkspaceCommand, GenerateModuleCommand, GenerateContextPluginCommand]; + + const commands = [ + IndexWorkspaceCommand, + GenerateModuleCommand, + GenerateContextPluginCommand, + CopyMagentoPathCommand, + GenerateXmlCatalogCommand, + GenerateObserverCommand, + ]; ExtensionState.init(context); commands.forEach(command => { const instance = new command(); - console.log('Registering command', instance.getCommand()); + Common.log('Registering command', instance.getCommand()); const disposable = vscode.commands.registerCommand(instance.getCommand(), (...args) => { instance.execute(...args); @@ -35,6 +51,7 @@ export async function activate(context: vscode.ExtensionContext) { const activeTextEditorChangeObserver = new ActiveTextEditorChangeObserver(); const changeTextEditorSelectionObserver = new ChangeTextEditorSelectionObserver(); + // window observers context.subscriptions.push( vscode.window.onDidChangeActiveTextEditor(event => { activeTextEditorChangeObserver.execute(event); @@ -48,6 +65,7 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + // workspace observers context.subscriptions.push( vscode.workspace.onDidChangeTextDocument(event => { DiagnosticCollectionProvider.updateDiagnostics(event.document); @@ -66,13 +84,28 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + // definition providers + context.subscriptions.push( + vscode.languages.registerDefinitionProvider('xml', new XmlClasslikeDefinitionProvider()) + ); + + // codelens providers + context.subscriptions.push( + vscode.languages.registerCodeLensProvider('php', new ObserverCodelensProvider()) + ); + + // hover providers + context.subscriptions.push( + vscode.languages.registerHoverProvider('xml', new XmlClasslikeHoverProvider()) + ); + await activeTextEditorChangeObserver.execute(vscode.window.activeTextEditor); if (vscode.window.activeTextEditor) { DiagnosticCollectionProvider.updateDiagnostics(vscode.window.activeTextEditor.document); } - console.log('[Magento Toolbox] Done'); + console.log('[Magento Toolbox] Loaded'); } // This method is called when your extension is deactivated diff --git a/src/generator/observer/ObserverClassGenerator.ts b/src/generator/observer/ObserverClassGenerator.ts new file mode 100644 index 0000000..7bfddb1 --- /dev/null +++ b/src/generator/observer/ObserverClassGenerator.ts @@ -0,0 +1,45 @@ +import PhpNamespace from 'common/PhpNamespace'; +import GeneratedFile from 'generator/GeneratedFile'; +import ModuleFileGenerator from 'generator/ModuleFileGenerator'; +import { PhpFile, PsrPrinter } from 'node-php-generator'; +import { Uri } from 'vscode'; +import { ObserverWizardData } from 'wizard/ObserverWizard'; + +export default class ObserverClassGenerator extends ModuleFileGenerator { + private static readonly OBSERVER_INTERFACE = 'Magento\\Framework\\Event\\ObserverInterface'; + private static readonly OBSERVER_CLASS = 'Magento\\Framework\\Event\\Observer'; + + public constructor(protected data: ObserverWizardData) { + super(); + } + + public async generate(workspaceUri: Uri): Promise { + const [vendor, module] = this.data.module.split('_'); + const namespaceParts = [vendor, module, this.data.directoryPath]; + const moduleDirectory = this.getModuleDirectory(vendor, module, workspaceUri); + + const phpFile = new PhpFile(); + phpFile.setStrictTypes(true); + + const namespace = phpFile.addNamespace(PhpNamespace.fromParts(namespaceParts).toString()); + namespace.addUse(ObserverClassGenerator.OBSERVER_INTERFACE); + namespace.addUse(ObserverClassGenerator.OBSERVER_CLASS); + + const observerClass = namespace.addClass(this.data.className); + observerClass.addImplement(ObserverClassGenerator.OBSERVER_INTERFACE); + + const observerMethod = observerClass.addMethod('execute'); + observerMethod.addParameter('observer').setType(ObserverClassGenerator.OBSERVER_CLASS); + observerMethod.addComment(`Observer for "${this.data.eventName}"\n`); + observerMethod.addComment('@param Observer $observer'); + observerMethod.addComment('@return void'); + observerMethod.setBody(`$event = $observer->getEvent();\n// TODO: Observer code`); + + const printer = new PsrPrinter(); + + return new GeneratedFile( + Uri.joinPath(moduleDirectory, this.data.directoryPath, `${this.data.className}.php`), + printer.printFile(phpFile) + ); + } +} diff --git a/src/generator/observer/ObserverEventsGenerator.ts b/src/generator/observer/ObserverEventsGenerator.ts new file mode 100644 index 0000000..97420cf --- /dev/null +++ b/src/generator/observer/ObserverEventsGenerator.ts @@ -0,0 +1,50 @@ +import GeneratedFile from 'generator/GeneratedFile'; +import ModuleFileGenerator from 'generator/ModuleFileGenerator'; +import GenerateFromTemplate from 'generator/util/GenerateFromTemplate'; +import { Uri } from 'vscode'; +import { ObserverWizardData } from 'wizard/ObserverWizard'; +import indentString from 'indent-string'; +import PhpNamespace from 'common/PhpNamespace'; +import FindOrCreateEventsXml from 'generator/util/FindOrCreateEventsXml'; +import { MagentoScope } from 'types'; + +export default class ObserverDiGenerator extends ModuleFileGenerator { + public constructor(protected data: ObserverWizardData) { + super(); + } + + public async generate(workspaceUri: Uri): Promise { + const [vendor, module] = this.data.module.split('_'); + const moduleDirectory = this.getModuleDirectory(vendor, module, workspaceUri); + const observerNamespace = PhpNamespace.fromParts([vendor, module, this.data.directoryPath]); + const areaPath = this.data.area === MagentoScope.Global ? '' : this.data.area; + + const eventsFile = Uri.joinPath(moduleDirectory, 'etc', areaPath, 'events.xml'); + const eventsXml = await FindOrCreateEventsXml.execute( + workspaceUri, + vendor, + module, + this.data.area + ); + const insertPosition = this.getInsertPosition(eventsXml); + + const observerXml = await GenerateFromTemplate.generate('xml/observer', { + name: this.data.observerName, + className: observerNamespace.append(this.data.className).toString(), + eventName: this.data.eventName, + }); + + const newEventsXml = + eventsXml.slice(0, insertPosition) + + '\n' + + indentString(observerXml, 4) + + '\n' + + eventsXml.slice(insertPosition); + + return new GeneratedFile(eventsFile, newEventsXml, false); + } + + private getInsertPosition(diXml: string): number { + return diXml.indexOf(''); + } +} diff --git a/src/generator/util/FindOrCreateDiXml.ts b/src/generator/util/FindOrCreateDiXml.ts index 4a0b8a8..4e293e7 100644 --- a/src/generator/util/FindOrCreateDiXml.ts +++ b/src/generator/util/FindOrCreateDiXml.ts @@ -2,8 +2,8 @@ import { Uri } from 'vscode'; import FileSystem from 'util/FileSystem'; import GenerateFromTemplate from './GenerateFromTemplate'; -class FindOrCreateDiXml { - public async execute(workspaceUri: Uri, vendor: string, module: string): Promise { +export default class FindOrCreateDiXml { + public static async execute(workspaceUri: Uri, vendor: string, module: string): Promise { const diFile = Uri.joinPath(workspaceUri, 'app', 'code', vendor, module, 'etc', 'di.xml'); if (await FileSystem.fileExists(diFile)) { @@ -13,5 +13,3 @@ class FindOrCreateDiXml { return await GenerateFromTemplate.generate('xml/blank-di'); } } - -export default new FindOrCreateDiXml(); diff --git a/src/generator/util/FindOrCreateEventsXml.ts b/src/generator/util/FindOrCreateEventsXml.ts new file mode 100644 index 0000000..6533163 --- /dev/null +++ b/src/generator/util/FindOrCreateEventsXml.ts @@ -0,0 +1,31 @@ +import { Uri } from 'vscode'; +import FileSystem from 'util/FileSystem'; +import GenerateFromTemplate from './GenerateFromTemplate'; +import { MagentoScope } from 'types'; + +export default class FindOrCreateEventsXml { + public static async execute( + workspaceUri: Uri, + vendor: string, + module: string, + area: MagentoScope + ): Promise { + const areaPath = area === MagentoScope.Global ? '' : area; + const eventsFile = Uri.joinPath( + workspaceUri, + 'app', + 'code', + vendor, + module, + 'etc', + areaPath, + 'events.xml' + ); + + if (await FileSystem.fileExists(eventsFile)) { + return await FileSystem.readFile(eventsFile); + } + + return await GenerateFromTemplate.generate('xml/blank-events'); + } +} diff --git a/src/generator/util/GenerateFromTemplate.ts b/src/generator/util/GenerateFromTemplate.ts index 3e451a6..702a5e5 100644 --- a/src/generator/util/GenerateFromTemplate.ts +++ b/src/generator/util/GenerateFromTemplate.ts @@ -1,15 +1,21 @@ -import { renderFile } from 'ejs'; +import { render } from 'ejs'; import { resolve } from 'path'; - -class GenerateFromTemplate { - public async generate(template: string, data?: any): Promise { - const content = await renderFile>(this.getTemplateDirectory(template), data); - return content; +import FileSystem from 'util/FileSystem'; +import { Uri } from 'vscode'; +export default class GenerateFromTemplate { + public static async generate(template: string, data?: any): Promise { + try { + const templatePath = this.getTemplatePath(template); + const templateContent = await FileSystem.readFile(Uri.file(templatePath)); + const content = render(templateContent, data); + return content; + } catch (error) { + console.error(error); + throw error; + } } - private getTemplateDirectory(templateName: string): string { + protected static getTemplatePath(templateName: string): string { return resolve(__dirname, 'templates', templateName + '.ejs'); } } - -export default new GenerateFromTemplate(); diff --git a/src/hover/XmlClasslikeHoverProvider.ts b/src/hover/XmlClasslikeHoverProvider.ts new file mode 100644 index 0000000..735bbef --- /dev/null +++ b/src/hover/XmlClasslikeHoverProvider.ts @@ -0,0 +1,44 @@ +import { ClasslikeInfo } from 'common/php/ClasslikeInfo'; +import PhpDocumentParser from 'common/php/PhpDocumentParser'; +import PhpNamespace from 'common/PhpNamespace'; +import AutoloadNamespaceIndexer from 'indexer/autoload-namespace/AutoloadNamespaceIndexer'; +import IndexManager from 'indexer/IndexManager'; +import { Hover, HoverProvider, Position, Range, TextDocument } from 'vscode'; + +export default class XmlClasslikeHoverProvider implements HoverProvider { + public async provideHover(document: TextDocument, position: Position): Promise { + const range = document.getWordRangeAtPosition(position, /("[^"]+")|(>[^<]+<)/); + + if (!range) { + return null; + } + + const word = document.getText(range); + + const namespaceIndexData = IndexManager.getIndexData(AutoloadNamespaceIndexer.KEY); + + if (!namespaceIndexData) { + return null; + } + + const potentialNamespace = word.replace(/["<>]/g, ''); + + const classUri = await namespaceIndexData.findClassByNamespace( + PhpNamespace.fromString(potentialNamespace) + ); + + if (!classUri) { + return null; + } + + const phpFile = await PhpDocumentParser.parseUri(document, classUri); + const classLikeInfo = new ClasslikeInfo(phpFile); + + const rangeWithoutTags = new Range( + range.start.with({ character: range.start.character + 1 }), + range.end.with({ character: range.end.character - 1 }) + ); + + return new Hover(classLikeInfo.getHover(), rangeWithoutTags); + } +} diff --git a/src/indexer/AbstractIndexData.ts b/src/indexer/AbstractIndexData.ts new file mode 100644 index 0000000..2920156 --- /dev/null +++ b/src/indexer/AbstractIndexData.ts @@ -0,0 +1,10 @@ +import { Memoize } from 'typescript-memoize'; + +export abstract class AbstractIndexData { + public constructor(protected data: Map) {} + + @Memoize() + public getValues(): T[] { + return Array.from(this.data.values()); + } +} diff --git a/src/indexer/AutoloadNamespaceIndexer.ts b/src/indexer/AutoloadNamespaceIndexer.ts deleted file mode 100644 index 2881495..0000000 --- a/src/indexer/AutoloadNamespaceIndexer.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { RelativePattern, Uri } from 'vscode'; -import { Indexer } from './Indexer'; -import PhpNamespace from 'common/PhpNamespace'; -import { AutoloadNamespaceIndexData } from './data/AutoloadNamespaceIndexData'; - -declare global { - interface IndexerData { - [AutoloadNamespaceIndexer.KEY]: AutoloadNamespaceIndexData; - } -} - -export default class AutoloadNamespaceIndexer extends Indexer { - public static readonly KEY = 'autoloadNamespace'; - - private data: AutoloadNamespaceIndexData; - - public constructor() { - super(); - this.data = new AutoloadNamespaceIndexData(); - } - - public getId(): keyof IndexerData { - return AutoloadNamespaceIndexer.KEY; - } - - public getName(): string { - return 'Autoload namespaces'; - } - - public getPattern(uri: Uri): RelativePattern { - return new RelativePattern(uri, '**/composer.json'); - } - - public async indexFile(uri: Uri): Promise { - const content = await this.readFile(uri); - const composer = JSON.parse(content); - - if (!composer.autoload) { - return; - } - - const baseDir = Uri.joinPath(uri, '..'); - - // Handle PSR-4 autoloading - if (composer.autoload['psr-4']) { - for (const [namespace, paths] of Object.entries(composer.autoload['psr-4'])) { - const directories = Array.isArray(paths) ? paths : [paths]; - - const namespaceData = { - namespace: PhpNamespace.fromString(namespace), - directories: directories.map((dir: string) => - Uri.joinPath(baseDir, dir.replace(/^\.\//, '')) - ), - }; - - this.data.namespaces.set(PhpNamespace.fromString(namespace).toString(), namespaceData); - } - } - - // Handle PSR-0 autoloading - if (composer.autoload['psr-0']) { - for (const [namespace, paths] of Object.entries(composer.autoload['psr-0'])) { - const directories = Array.isArray(paths) ? paths : [paths]; - - const namespaceData = { - namespace: PhpNamespace.fromString(namespace), - directories: directories.map((dir: string) => - Uri.joinPath(baseDir, dir.replace(/^\.\//, '')) - ), - }; - - this.data.namespaces.set(PhpNamespace.fromString(namespace).toString(), namespaceData); - } - } - } - - public getData(): AutoloadNamespaceIndexData { - return this.data; - } - - public clear(): void { - this.data = new AutoloadNamespaceIndexData(); - } -} diff --git a/src/indexer/IndexManager.ts b/src/indexer/IndexManager.ts index cb2407a..3cb97cf 100644 --- a/src/indexer/IndexManager.ts +++ b/src/indexer/IndexManager.ts @@ -1,17 +1,46 @@ import { Progress, Uri, workspace, WorkspaceFolder } from 'vscode'; import { Indexer } from './Indexer'; -import IndexStorage from 'common/IndexStorage'; import Common from 'util/Common'; import { minimatch } from 'minimatch'; +import DiIndexer from './di/DiIndexer'; +import IndexStorage from './IndexStorage'; +import ModuleIndexer from './module/ModuleIndexer'; +import AutoloadNamespaceIndexer from './autoload-namespace/AutoloadNamespaceIndexer'; +import { clear } from 'typescript-memoize'; +import EventsIndexer from './events/EventsIndexer'; +import { DiIndexData } from './di/DiIndexData'; +import { ModuleIndexData } from './module/ModuleIndexData'; +import { AutoloadNamespaceIndexData } from './autoload-namespace/AutoloadNamespaceIndexData'; +import { EventsIndexData } from './events/EventsIndexData'; + +type IndexerInstance = DiIndexer | ModuleIndexer | AutoloadNamespaceIndexer | EventsIndexer; + +type IndexerDataMap = { + [DiIndexer.KEY]: DiIndexData; + [ModuleIndexer.KEY]: ModuleIndexData; + [AutoloadNamespaceIndexer.KEY]: AutoloadNamespaceIndexData; + [EventsIndexer.KEY]: EventsIndexData; +}; + +class IndexManager { + protected indexers: IndexerInstance[] = []; + protected indexStorage: IndexStorage; + + public constructor() { + this.indexers = [ + new DiIndexer(), + new ModuleIndexer(), + new AutoloadNamespaceIndexer(), + new EventsIndexer(), + ]; + this.indexStorage = new IndexStorage(); + } -export default class IndexManager { - public constructor(private readonly indexers: Indexer[]) {} - - public getIndexers(): Indexer[] { + public getIndexers(): IndexerInstance[] { return this.indexers; } - public getIndexer(name: string): I | undefined { + public getIndexer(name: string): I | undefined { return this.indexers.find(index => index.getName() === name) as I | undefined; } @@ -28,20 +57,39 @@ export default class IndexManager { if (!force && !this.shouldIndex(indexer)) { continue; } + progress.report({ message: `Indexing - ${indexer.getName()}`, increment: 0 }); + + const indexData = this.getIndexStorageData(indexer.getId()) || new Map(); const timer = `indexer_${indexer.getId()}`; Common.startStopwatch(timer); const files = await workspace.findFiles(indexer.getPattern(workspaceUri), 'dev/**'); - progress.report({ message: `Running indexer - ${indexer.getName()}`, increment: 0 }); + let doneCount = 0; + const totalCount = files.length; + + await Promise.all( + files.map(async file => { + const data = await indexer.indexFile(file); + + if (data !== undefined) { + indexData.set(file.fsPath, data); + } - const promises = files.map(file => indexer.indexFile(file)); - await Promise.all(promises); + doneCount++; + const pct = Math.round((doneCount / totalCount) * 100); - const indexData = indexer.getData(); - IndexStorage.set(indexer.getId(), indexData); + progress.report({ + message: `Indexing - ${indexer.getName()} [${doneCount}/${totalCount}]`, + increment: pct, + }); + }) + ); + + this.indexStorage.set(workspaceFolder, indexer.getId(), indexData); + + clear([indexer.getId()]); - indexer.clear(); Common.stopStopwatch(timer); progress.report({ increment: 100 }); @@ -53,29 +101,90 @@ export default class IndexManager { public async indexFile(workspaceFolder: WorkspaceFolder, file: Uri): Promise { Common.startStopwatch('indexFile'); + await Promise.all( + this.indexers.map(async indexer => { + await this.indexFileInner(workspaceFolder, file, indexer); + }) + ); + + Common.stopStopwatch('indexFile'); + } + + public async indexFiles(workspaceFolder: WorkspaceFolder, files: Uri[]): Promise { + Common.startStopwatch('indexFiles'); + for (const indexer of this.indexers) { - const pattern = indexer.getPattern(workspaceFolder.uri); - const patternString = typeof pattern === 'string' ? pattern : pattern.pattern; + await Promise.all(files.map(file => this.indexFileInner(workspaceFolder, file, indexer))); + } - if (minimatch(file.fsPath, patternString, { matchBase: true })) { - await indexer.indexFile(file); - } + Common.stopStopwatch('indexFiles'); + } + + public getIndexStorageData( + id: string, + workspaceFolder?: WorkspaceFolder + ): Map | undefined { + const wf = workspaceFolder || Common.getActiveWorkspaceFolder(); + + if (!wf) { + return undefined; } - Common.stopStopwatch('indexFile'); + return this.indexStorage.get(wf, id); } - public async indexFiles(workspaceFolder: WorkspaceFolder, files: Uri[]): Promise { - await Promise.all(files.map(file => this.indexFile(workspaceFolder, file))); + public getIndexData( + id: T, + workspaceFolder?: WorkspaceFolder + ): IndexerDataMap[T] | undefined { + const data = this.getIndexStorageData(id, workspaceFolder); + + if (!data) { + return undefined; + } + + if (id === DiIndexer.KEY) { + return new DiIndexData(data) as IndexerDataMap[T]; + } + + if (id === ModuleIndexer.KEY) { + return new ModuleIndexData(data) as IndexerDataMap[T]; + } + + if (id === AutoloadNamespaceIndexer.KEY) { + return new AutoloadNamespaceIndexData(data) as IndexerDataMap[T]; + } + + if (id === EventsIndexer.KEY) { + return new EventsIndexData(data) as IndexerDataMap[T]; + } + + return undefined; } - protected shouldIndex(index: Indexer): boolean { - const indexData = IndexStorage.get(index.getId()); + protected async indexFileInner( + workspaceFolder: WorkspaceFolder, + file: Uri, + indexer: Indexer + ): Promise { + const indexData = this.getIndexStorageData(indexer.getId()) || new Map(); + const pattern = indexer.getPattern(workspaceFolder.uri); + const patternString = typeof pattern === 'string' ? pattern : pattern.pattern; + + if (minimatch(file.fsPath, patternString, { matchBase: true })) { + const data = await indexer.indexFile(file); - if (!indexData) { - return true; + if (data !== undefined) { + indexData.set(file.fsPath, data); + } } - return false; + clear([indexer.getId()]); + } + + protected shouldIndex(index: IndexerInstance): boolean { + return true; } } + +export default new IndexManager(); diff --git a/src/indexer/IndexRunner.ts b/src/indexer/IndexRunner.ts index 87da1dc..dae4b5b 100644 --- a/src/indexer/IndexRunner.ts +++ b/src/indexer/IndexRunner.ts @@ -1,28 +1,8 @@ import * as vscode from 'vscode'; -import AutoloadNamespaceIndexer from './AutoloadNamespaceIndexer'; -import DiIndexer from './DiIndexer'; import IndexManager from './IndexManager'; -import ModuleIndexer from './ModuleIndexer'; -import IndexStorage from 'common/IndexStorage'; class IndexRunner { - public indexManager: IndexManager; - - public constructor() { - this.indexManager = new IndexManager([ - new ModuleIndexer(), - new AutoloadNamespaceIndexer(), - new DiIndexer(), - ]); - } - public async indexWorkspace(force: boolean = false): Promise { - if (force) { - IndexStorage.clear(); - } else { - await IndexStorage.load(); - } - if (!vscode.workspace.workspaceFolders) { return; } @@ -34,23 +14,21 @@ class IndexRunner { title: '[Magento Toolbox]', }, async progress => { - await this.indexManager.indexWorkspace(workspaceFolder, progress); + await IndexManager.indexWorkspace(workspaceFolder, progress); } ); } - - await IndexStorage.save(); } public async indexFile(workspaceFolder: vscode.WorkspaceFolder, file: vscode.Uri): Promise { - await this.indexManager.indexFile(workspaceFolder, file); + await IndexManager.indexFile(workspaceFolder, file); } public async indexFiles( workspaceFolder: vscode.WorkspaceFolder, files: vscode.Uri[] ): Promise { - await this.indexManager.indexFiles(workspaceFolder, files); + await IndexManager.indexFiles(workspaceFolder, files); } } diff --git a/src/indexer/IndexStorage.ts b/src/indexer/IndexStorage.ts new file mode 100644 index 0000000..b9da962 --- /dev/null +++ b/src/indexer/IndexStorage.ts @@ -0,0 +1,31 @@ +import { WorkspaceFolder } from 'vscode'; + +export type IndexerStorage = Record>>; + +export default class IndexStorage { + private _indexStorage: IndexerStorage = {}; + + public set(workspaceFolder: WorkspaceFolder, key: string, value: any) { + if (!this._indexStorage[workspaceFolder.uri.fsPath]) { + this._indexStorage[workspaceFolder.uri.fsPath] = {}; + } + + this._indexStorage[workspaceFolder.uri.fsPath][key] = value; + } + + public get(workspaceFolder: WorkspaceFolder, key: string): Map | undefined { + return this._indexStorage[workspaceFolder.uri.fsPath]?.[key]; + } + + public clear() { + this._indexStorage = {}; + } + + public async load() { + // TODO: Implement + } + + public async save() { + // TODO: Implement + } +} diff --git a/src/indexer/Indexer.ts b/src/indexer/Indexer.ts index fc85141..7306207 100644 --- a/src/indexer/Indexer.ts +++ b/src/indexer/Indexer.ts @@ -1,20 +1,8 @@ -import { workspace, type GlobPattern, type Uri } from 'vscode'; +import { type GlobPattern, type Uri } from 'vscode'; -declare global { - interface IndexerData {} -} - -export abstract class Indexer { - public abstract getId(): keyof IndexerData; +export abstract class Indexer { + public abstract getId(): string; public abstract getName(): string; public abstract getPattern(uri: Uri): GlobPattern; - public abstract indexFile(uri: Uri): Promise; - public abstract getData(): any; - public abstract clear(): void; - - public async readFile(uri: Uri): Promise { - const data = await workspace.fs.readFile(uri); - - return data.toString(); - } + public abstract indexFile(uri: Uri): Promise; } diff --git a/src/indexer/autoload-namespace/AutoloadNamespaceIndexData.ts b/src/indexer/autoload-namespace/AutoloadNamespaceIndexData.ts new file mode 100644 index 0000000..f09ecf1 --- /dev/null +++ b/src/indexer/autoload-namespace/AutoloadNamespaceIndexData.ts @@ -0,0 +1,78 @@ +import { Uri } from 'vscode'; +import PhpNamespace from 'common/PhpNamespace'; +import FileSystem from 'util/FileSystem'; +import { AbstractIndexData } from 'indexer/AbstractIndexData'; +import { AutoloadNamespaceData } from './types'; +import { Memoize } from 'typescript-memoize'; +import AutoloadNamespaceIndexer from './AutoloadNamespaceIndexer'; + +export class AutoloadNamespaceIndexData extends AbstractIndexData { + private static readonly SPECIAL_CLASSNAMES = ['Proxy', 'Factory']; + + @Memoize({ + tags: [AutoloadNamespaceIndexer.KEY], + hashFunction: (namespace: PhpNamespace) => namespace.toString(), + }) + public async findClassByNamespace(namespace: PhpNamespace): Promise { + const parts = namespace.getParts(); + + for (let i = parts.length; i >= 0; i--) { + const namespace = PhpNamespace.fromParts(parts.slice(0, i)).toString(); + + const directories = this.getDirectoriesByNamespace(namespace); + + if (directories.length === 0) { + continue; + } + + let className = parts.pop() as string; + + if (AutoloadNamespaceIndexData.SPECIAL_CLASSNAMES.includes(className)) { + className = parts.pop() as string; + } + + const classNamespace = PhpNamespace.fromParts(parts.slice(i)).append(className); + + const directory = await this.findNamespaceDirectory(classNamespace, directories); + + if (!directory) { + continue; + } + + const classPath = classNamespace.toString().replace(/\\/g, '/'); + const fileUri = Uri.joinPath(directory, `${classPath}.php`); + + return fileUri; + } + + return undefined; + } + + private getDirectoriesByNamespace(namespace: string): string[] { + const namespaceData = this.getValues().filter(data => data[namespace] !== undefined); + + if (!namespaceData) { + return []; + } + + return namespaceData.flatMap(data => data[namespace] ?? []); + } + + private async findNamespaceDirectory( + namespace: PhpNamespace, + directories: string[] + ): Promise { + for (const directory of directories) { + const directoryUri = Uri.file(directory); + const classPath = namespace.toString().replace(/\\/g, '/'); + const fileUri = Uri.joinPath(directoryUri, `${classPath}.php`); + const exists = await FileSystem.fileExists(fileUri); + + if (exists) { + return directoryUri; + } + } + + return undefined; + } +} diff --git a/src/indexer/autoload-namespace/AutoloadNamespaceIndexer.ts b/src/indexer/autoload-namespace/AutoloadNamespaceIndexer.ts new file mode 100644 index 0000000..7523a09 --- /dev/null +++ b/src/indexer/autoload-namespace/AutoloadNamespaceIndexer.ts @@ -0,0 +1,60 @@ +import { RelativePattern, Uri } from 'vscode'; +import { Indexer } from 'indexer/Indexer'; +import FileSystem from 'util/FileSystem'; +import { AutoloadNamespaceData } from './types'; + +export default class AutoloadNamespaceIndexer extends Indexer { + public static readonly KEY = 'autoloadNamespace'; + + public getId(): string { + return AutoloadNamespaceIndexer.KEY; + } + + public getName(): string { + return 'namespaces'; + } + + public getPattern(uri: Uri): RelativePattern { + return new RelativePattern(uri, '**/composer.json'); + } + + public async indexFile(uri: Uri): Promise { + const content = await FileSystem.readFile(uri); + const composer = JSON.parse(content); + + if (!composer.autoload) { + return; + } + + const baseDir = Uri.joinPath(uri, '..'); + const data: AutoloadNamespaceData = {}; + + // Handle PSR-4 autoloading + if (composer.autoload['psr-4']) { + for (const [namespace, paths] of Object.entries(composer.autoload['psr-4'])) { + const directories = Array.isArray(paths) ? paths : [paths]; + + data[this.normalizeNamespace(namespace)] = directories.map( + (dir: string) => Uri.joinPath(baseDir, dir.replace(/^\.\//, '')).fsPath + ); + } + } + + // Handle PSR-0 autoloading + if (composer.autoload['psr-0']) { + for (const [namespace, paths] of Object.entries(composer.autoload['psr-0'])) { + const directories = Array.isArray(paths) ? paths : [paths]; + + data[this.normalizeNamespace(namespace)] = directories.map( + (dir: string) => Uri.joinPath(baseDir, dir.replace(/^\.\//, '')).fsPath + ); + } + } + + return data; + } + + private normalizeNamespace(namespace: string): string { + return namespace.replace(/\\$/, ''); + } +} diff --git a/src/indexer/autoload-namespace/types.ts b/src/indexer/autoload-namespace/types.ts new file mode 100644 index 0000000..d146239 --- /dev/null +++ b/src/indexer/autoload-namespace/types.ts @@ -0,0 +1 @@ +export type AutoloadNamespaceData = Record; diff --git a/src/indexer/data/AutoloadNamespaceIndexData.ts b/src/indexer/data/AutoloadNamespaceIndexData.ts deleted file mode 100644 index 51d33e9..0000000 --- a/src/indexer/data/AutoloadNamespaceIndexData.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Uri } from 'vscode'; -import IndexData from './IndexData'; -import PhpNamespace from 'common/PhpNamespace'; -import FileSystem from 'util/FileSystem'; -import { JsonObject } from 'typescript-json-serializer'; - -export interface AutoloadNamespaceData { - namespace: PhpNamespace; - directories: Uri[]; -} - -@JsonObject() -export class AutoloadNamespaceIndexData extends IndexData { - public namespaces: Map; - - public constructor(namespaces: Map = new Map()) { - super(); - this.namespaces = namespaces; - } - - public getNamespaces(): Map { - return this.namespaces; - } - - public async findClassByNamespace(namespace: PhpNamespace): Promise { - const parts = namespace.getParts(); - - for (let i = parts.length; i > 0; i--) { - const namespace = PhpNamespace.fromParts(parts.slice(0, i)); - - const namespaceData = this.namespaces.get(namespace.toString()); - - if (!namespaceData) { - continue; - } - - const className = parts.pop() as string; - const classNamespace = PhpNamespace.fromParts(parts.slice(i)).append(className); - - const directory = await this.findNamespaceDirectory( - classNamespace, - namespaceData.directories - ); - - if (!directory) { - continue; - } - - const classPath = classNamespace.toString().replace(/\\/g, '/'); - const fileUri = Uri.joinPath(directory, `${classPath}.php`); - - return fileUri; - } - - return undefined; - } - - private async findNamespaceDirectory( - namespace: PhpNamespace, - directories: Uri[] - ): Promise { - for (const directory of directories) { - const classPath = namespace.toString().replace(/\\/g, '/'); - const fileUri = Uri.joinPath(directory, `${classPath}.php`); - const exists = await FileSystem.fileExists(fileUri); - - if (exists) { - return directory; - } - } - - return undefined; - } -} diff --git a/src/indexer/data/DiIndexData.ts b/src/indexer/data/DiIndexData.ts deleted file mode 100644 index d4a7437..0000000 --- a/src/indexer/data/DiIndexData.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Uri } from 'vscode'; -import IndexData from './IndexData'; -import { JsonObject } from 'typescript-json-serializer'; - -export interface DiPlugin { - name: string; - type: string; - before?: string; - after?: string; - disabled?: boolean; - sortOrder?: number; - diUri: Uri; -} - -export interface DiArguments { - [key: string]: any; -} - -export interface DiPreference { - for: string; - type: string; - diUri: Uri; -} - -export interface DiBaseType { - name: string; - shared?: boolean; - arguments?: DiArguments; - diUri: Uri; -} - -export interface DiType extends DiBaseType { - plugins: DiPlugin[]; -} - -export interface DiVirtualType extends DiBaseType { - type: string; -} - -@JsonObject() -export class DiIndexData extends IndexData { - public constructor( - public types: DiType[] = [], - public preferences: DiPreference[] = [], - public virtualTypes: DiVirtualType[] = [] - ) { - super(); - } - - public getTypes(): DiType[] { - return this.types; - } - - public getPreferences(): DiPreference[] { - return this.preferences; - } - - public getVirtualTypes(): DiVirtualType[] { - return this.virtualTypes; - } - - public findTypeByName(name: string): DiType | DiVirtualType | undefined { - return ( - this.types.find(type => type.name === name) || - this.virtualTypes.find(type => type.name === name) - ); - } - - public findPreferencesByType(type: string): DiPreference[] { - return this.preferences.filter(pref => pref.for === type); - } - - public findPluginsForType(type: string): DiPlugin[] { - const foundType = this.types.find(t => t.name === type); - return foundType?.plugins || []; - } -} diff --git a/src/indexer/data/IndexData.ts b/src/indexer/data/IndexData.ts deleted file mode 100644 index 4c9211a..0000000 --- a/src/indexer/data/IndexData.ts +++ /dev/null @@ -1 +0,0 @@ -export default abstract class IndexData {} diff --git a/src/indexer/data/ModuleIndexData.ts b/src/indexer/data/ModuleIndexData.ts deleted file mode 100644 index dd85560..0000000 --- a/src/indexer/data/ModuleIndexData.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Uri } from 'vscode'; -import { WizardSelectOption } from 'webview/types'; -import IndexData from './IndexData'; -import { JsonObject } from 'typescript-json-serializer'; - -export interface Module { - name: string; - version?: string; - sequence: string[]; - uri: Uri; - location: 'vendor' | 'app'; -} - -@JsonObject() -export class ModuleIndexData extends IndexData { - public constructor(public modules: Module[] = []) { - super(); - } - - public getModules(): Module[] { - return this.modules; - } - - public getModuleOptions(filter?: (module: Module) => boolean): WizardSelectOption[] { - return this.modules - .filter(module => !filter || filter(module)) - .map(module => ({ - label: module.name, - value: module.name, - })); - } - - public getModule(name: string): Module | undefined { - return this.modules.find(module => module.name === name); - } -} diff --git a/src/indexer/di/DiIndexData.ts b/src/indexer/di/DiIndexData.ts new file mode 100644 index 0000000..7af64eb --- /dev/null +++ b/src/indexer/di/DiIndexData.ts @@ -0,0 +1,45 @@ +import { Memoize } from 'typescript-memoize'; +import { DiData, DiPlugin, DiPreference, DiType, DiVirtualType } from './types'; +import { AbstractIndexData } from 'indexer/AbstractIndexData'; +import DiIndexer from './DiIndexer'; + +export class DiIndexData extends AbstractIndexData { + @Memoize({ + tags: [DiIndexer.KEY], + }) + public getTypes(): DiType[] { + return this.getValues().flatMap(data => data.types); + } + + @Memoize({ + tags: [DiIndexer.KEY], + }) + public getPreferences(): DiPreference[] { + return this.getValues().flatMap(data => data.preferences); + } + + @Memoize({ + tags: [DiIndexer.KEY], + }) + public getVirtualTypes(): DiVirtualType[] { + return this.getValues().flatMap(data => data.virtualTypes); + } + + public findTypesByName(name: string): DiType[] { + return this.getTypes().filter(type => type.name === name); + } + + public findVirtualTypeByName(name: string): DiVirtualType | undefined { + return this.getVirtualTypes().find(type => type.name === name); + } + + public findPreferencesByType(type: string): DiPreference[] { + return this.getPreferences().filter(pref => pref.for === type); + } + + public findPluginsForType(type: string): DiPlugin[] { + const typeData = this.findTypesByName(type); + + return typeData.flatMap(type => type.plugins); + } +} diff --git a/src/indexer/DiIndexer.ts b/src/indexer/di/DiIndexer.ts similarity index 70% rename from src/indexer/DiIndexer.ts rename to src/indexer/di/DiIndexer.ts index 6155258..3909f8e 100644 --- a/src/indexer/DiIndexer.ts +++ b/src/indexer/di/DiIndexer.ts @@ -1,39 +1,22 @@ import { RelativePattern, Uri } from 'vscode'; -import { Indexer } from './Indexer'; import { XMLParser } from 'fast-xml-parser'; import { get } from 'lodash-es'; -import { DiIndexData, DiType, DiPreference, DiPlugin, DiVirtualType } from './data/DiIndexData'; +import FileSystem from 'util/FileSystem'; +import { DiData, DiPlugin, DiPreference, DiType, DiVirtualType } from './types'; +import { Indexer } from 'indexer/Indexer'; -declare global { - interface IndexerData { - [DiIndexer.KEY]: DiIndexData; - } -} - -export default class DiIndexer extends Indexer { +export default class DiIndexer extends Indexer { public static readonly KEY = 'di'; - private data: { - types: DiType[]; - preferences: DiPreference[]; - virtualTypes: DiVirtualType[]; - }; - - private xmlParser: XMLParser; + protected xmlParser: XMLParser; public constructor() { super(); - this.data = { - types: [], - preferences: [], - virtualTypes: [], - }; - this.xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', - isArray: (name, jpath) => { + isArray: (_name, jpath) => { return [ 'config.type', 'config.preference', @@ -46,22 +29,28 @@ export default class DiIndexer extends Indexer { }); } - public getId(): keyof IndexerData { + public getId(): string { return DiIndexer.KEY; } public getName(): string { - return 'Dependency Injection indexer'; + return 'di.xml'; } public getPattern(uri: Uri): RelativePattern { return new RelativePattern(uri, '**/etc/di.xml'); } - public async indexFile(uri: Uri): Promise { - const xml = await this.readFile(uri); + public async indexFile(uri: Uri): Promise { + const xml = await FileSystem.readFile(uri); const parsed = this.xmlParser.parse(xml); const config = get(parsed, 'config', {}); + const data: DiData = { + types: [], + preferences: [], + virtualTypes: [], + plugins: [], + }; // Index types const types = get(config, 'type', []); @@ -72,8 +61,8 @@ export default class DiIndexer extends Indexer { const typeData: DiType = { name: typeName, plugins: [], - diUri: uri, shared: type['@_shared'] === 'false' ? false : undefined, + diPath: uri.fsPath, }; // Handle plugins @@ -88,7 +77,7 @@ export default class DiIndexer extends Indexer { disabled: plugin['@_disabled'] === 'true', before: plugin['@_before'], after: plugin['@_after'], - diUri: uri, + diPath: uri.fsPath, }; typeData.plugins.push(pluginData); } @@ -100,7 +89,7 @@ export default class DiIndexer extends Indexer { typeData.arguments = arguments_; } - this.data.types.push(typeData); + data.types.push(typeData); } // Index preferences @@ -109,9 +98,9 @@ export default class DiIndexer extends Indexer { const preference: DiPreference = { for: pref['@_for'], type: pref['@_type'], - diUri: uri, + diPath: uri.fsPath, }; - this.data.preferences.push(preference); + data.preferences.push(preference); } // Index virtual types @@ -121,7 +110,7 @@ export default class DiIndexer extends Indexer { name: vType['@_name'], type: vType['@_type'], shared: vType['@_shared'] === 'false' ? false : undefined, - diUri: uri, + diPath: uri.fsPath, }; // Handle arguments @@ -130,23 +119,9 @@ export default class DiIndexer extends Indexer { virtualType.arguments = arguments_; } - this.data.virtualTypes.push(virtualType); + data.virtualTypes.push(virtualType); } - } - - public getData(): DiIndexData { - return new DiIndexData( - [...this.data.types], - [...this.data.preferences], - [...this.data.virtualTypes] - ); - } - public clear(): void { - this.data = { - types: [], - preferences: [], - virtualTypes: [], - }; + return data; } } diff --git a/src/indexer/di/types.ts b/src/indexer/di/types.ts new file mode 100644 index 0000000..a390d72 --- /dev/null +++ b/src/indexer/di/types.ts @@ -0,0 +1,41 @@ +export interface DiData { + types: DiType[]; + preferences: DiPreference[]; + virtualTypes: DiVirtualType[]; + plugins: DiPlugin[]; +} + +export interface DiPlugin { + name: string; + type: string; + before?: string; + after?: string; + disabled?: boolean; + sortOrder?: number; + diPath: string; +} + +export interface DiArguments { + [key: string]: any; +} + +export interface DiPreference { + for: string; + type: string; + diPath: string; +} + +export interface DiBaseType { + name: string; + shared?: boolean; + arguments?: DiArguments; + diPath: string; +} + +export interface DiType extends DiBaseType { + plugins: DiPlugin[]; +} + +export interface DiVirtualType extends DiBaseType { + type: string; +} diff --git a/src/indexer/events/EventsIndexData.ts b/src/indexer/events/EventsIndexData.ts new file mode 100644 index 0000000..9ba253c --- /dev/null +++ b/src/indexer/events/EventsIndexData.ts @@ -0,0 +1,25 @@ +import { Memoize } from 'typescript-memoize'; +import EventsIndexer from './EventsIndexer'; +import { Event } from './types'; +import { AbstractIndexData } from 'indexer/AbstractIndexData'; + +export class EventsIndexData extends AbstractIndexData { + @Memoize({ tags: [EventsIndexer.KEY] }) + public getEvents(): Event[] { + return this.getValues().flat(); + } + + public getEventNames(): string[] { + return this.getEvents().map(event => event.name); + } + + public getEventByName(eventName: string): Event | undefined { + return this.getEvents().find(event => event.name === eventName); + } + + public findEventsByObserverInstance(observerInstance: string): Event[] { + return this.getEvents().filter(event => + event.observers.some(observer => observer.instance === observerInstance) + ); + } +} diff --git a/src/indexer/events/EventsIndexer.ts b/src/indexer/events/EventsIndexer.ts new file mode 100644 index 0000000..4d69212 --- /dev/null +++ b/src/indexer/events/EventsIndexer.ts @@ -0,0 +1,53 @@ +import { RelativePattern, Uri } from 'vscode'; +import { XMLParser } from 'fast-xml-parser'; +import { get } from 'lodash-es'; +import { Indexer } from 'indexer/Indexer'; +import FileSystem from 'util/FileSystem'; + +export default class EventsIndexer extends Indexer { + public static readonly KEY = 'events'; + + private xmlParser: XMLParser; + + public constructor() { + super(); + + this.xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name, jpath) => { + return ['config.event', 'config.event.observer'].includes(jpath); + }, + }); + } + + public getId(): string { + return EventsIndexer.KEY; + } + + public getName(): string { + return 'events.xml'; + } + + public getPattern(uri: Uri): RelativePattern { + return new RelativePattern(uri, '**/etc/events.xml'); + } + + public async indexFile(uri: Uri): Promise { + const xml = await FileSystem.readFile(uri); + + const parsed = this.xmlParser.parse(xml); + + const events = get(parsed, 'config.event', []); + + return events.map((event: any) => ({ + name: event['@_name'], + diPath: uri.fsPath, + observers: + event.observer?.map((observer: any) => ({ + name: observer['@_name'], + instance: observer['@_instance'], + })) || [], + })); + } +} diff --git a/src/indexer/events/types.ts b/src/indexer/events/types.ts new file mode 100644 index 0000000..2fd23f0 --- /dev/null +++ b/src/indexer/events/types.ts @@ -0,0 +1,10 @@ +export interface Event { + name: string; + observers: Observer[]; + diPath: string; +} + +export interface Observer { + name: string; + instance: string; +} diff --git a/src/indexer/module/ModuleIndexData.ts b/src/indexer/module/ModuleIndexData.ts new file mode 100644 index 0000000..a74bbf6 --- /dev/null +++ b/src/indexer/module/ModuleIndexData.ts @@ -0,0 +1,27 @@ +import { WizardSelectOption } from 'webview/types'; +import { Module } from './types'; +import { Uri } from 'vscode'; +import { AbstractIndexData } from 'indexer/AbstractIndexData'; + +export class ModuleIndexData extends AbstractIndexData { + public getModuleOptions(filter?: (module: Module) => boolean): WizardSelectOption[] { + return this.getValues() + .filter(module => !filter || filter(module)) + .map(module => ({ + label: module.name, + value: module.name, + })); + } + + public getModule(name: string): Module | undefined { + return this.getValues().find(module => module.name === name); + } + + public getModuleByUri(uri: Uri): Module | undefined { + const module = this.getValues().find(module => { + return uri.fsPath.startsWith(module.path); + }); + + return module; + } +} diff --git a/src/indexer/ModuleIndexer.ts b/src/indexer/module/ModuleIndexer.ts similarity index 53% rename from src/indexer/ModuleIndexer.ts rename to src/indexer/module/ModuleIndexer.ts index 258c5cd..e12527f 100644 --- a/src/indexer/ModuleIndexer.ts +++ b/src/indexer/module/ModuleIndexer.ts @@ -1,31 +1,18 @@ import { RelativePattern, Uri } from 'vscode'; -import { Indexer } from './Indexer'; import { XMLParser } from 'fast-xml-parser'; import { get } from 'lodash-es'; -import { Module, ModuleIndexData } from './data/ModuleIndexData'; +import { Module } from './types'; +import { Indexer } from 'indexer/Indexer'; +import FileSystem from 'util/FileSystem'; -declare global { - interface IndexerData { - [ModuleIndexer.KEY]: ModuleIndexData; - } -} - -export default class ModuleIndexer extends Indexer { - public static readonly KEY = 'moduleName'; - - private data: { - modules: Module[]; - }; +export default class ModuleIndexer extends Indexer { + public static readonly KEY = 'module'; private xmlParser: XMLParser; public constructor() { super(); - this.data = { - modules: [], - }; - this.xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', @@ -35,20 +22,20 @@ export default class ModuleIndexer extends Indexer { }); } - public getId(): keyof IndexerData { + public getId(): string { return ModuleIndexer.KEY; } public getName(): string { - return 'Module indexer'; + return 'module.xml'; } public getPattern(uri: Uri): RelativePattern { return new RelativePattern(uri, '**/etc/module.xml'); } - public async indexFile(uri: Uri): Promise { - const xml = await this.readFile(uri); + public async indexFile(uri: Uri): Promise { + const xml = await FileSystem.readFile(uri); const parsed = this.xmlParser.parse(xml); @@ -56,20 +43,12 @@ export default class ModuleIndexer extends Indexer { const setupVersion = get(parsed, 'config.module.@_setup_version'); const sequence = get(parsed, 'config.module.sequence.module', []); - this.data.modules.push({ + return { name: moduleName, version: setupVersion, sequence: sequence.map((module: any) => module['@_name']), - uri: Uri.joinPath(uri, '..', '..'), - location: uri.path.includes('vendor') ? 'vendor' : 'app', - }); - } - - public getData(): ModuleIndexData { - return new ModuleIndexData([...this.data.modules]); - } - - public clear(): void { - this.data.modules = []; + path: Uri.joinPath(uri, '..', '..').fsPath, + location: uri.fsPath.includes('vendor') ? 'vendor' : 'app', + }; } } diff --git a/src/indexer/module/types.ts b/src/indexer/module/types.ts new file mode 100644 index 0000000..47c0a4a --- /dev/null +++ b/src/indexer/module/types.ts @@ -0,0 +1,7 @@ +export interface Module { + name: string; + version?: string; + sequence: string[]; + path: string; + location: 'vendor' | 'app'; +} diff --git a/src/observer/ActiveTextEditorChangeObserver.ts b/src/observer/ActiveTextEditorChangeObserver.ts index db1c143..80ba9fc 100644 --- a/src/observer/ActiveTextEditorChangeObserver.ts +++ b/src/observer/ActiveTextEditorChangeObserver.ts @@ -2,16 +2,23 @@ import { TextEditor } from 'vscode'; import Observer from './Observer'; import PluginClassDecorationProvider from 'decorator/PluginClassDecorationProvider'; import Context from 'common/Context'; +import ObserverInstanceDecorationProvider from 'decorator/ObserverInstanceDecorationProvider'; export default class ActiveTextEditorChangeObserver extends Observer { public async execute(textEditor: TextEditor | undefined): Promise { await Context.updateContext('editor', textEditor); if (textEditor && textEditor.document.languageId === 'php') { - const provider = new PluginClassDecorationProvider(textEditor.document); + const pluginProvider = new PluginClassDecorationProvider(textEditor.document); + const observerProvider = new ObserverInstanceDecorationProvider(textEditor.document); - const decorations = await provider.getDecorations(); - textEditor.setDecorations(provider.getType(), decorations); + const [pluginDecorations, observerDecorations] = await Promise.all([ + pluginProvider.getDecorations(), + observerProvider.getDecorations(), + ]); + + textEditor.setDecorations(pluginProvider.getType(), pluginDecorations); + textEditor.setDecorations(observerProvider.getType(), observerDecorations); } } } diff --git a/src/parser/php/Parser.ts b/src/parser/php/Parser.ts index 45a7a33..6f259a3 100644 --- a/src/parser/php/Parser.ts +++ b/src/parser/php/Parser.ts @@ -10,6 +10,8 @@ export enum NodeKind { UseGroup = 'usegroup', UseItem = 'useitem', Method = 'method', + Call = 'call', + String = 'string', } export type KindType = K extends NodeKind.Program @@ -35,6 +37,8 @@ export default class PhpParser { this.parser = new php.Engine({ ast: { withPositions: true, + }, + parser: { extractDoc: true, }, }); diff --git a/src/parser/php/PhpClass.ts b/src/parser/php/PhpClass.ts index a69f79e..2ef3584 100644 --- a/src/parser/php/PhpClass.ts +++ b/src/parser/php/PhpClass.ts @@ -23,4 +23,16 @@ export class PhpClass extends PhpNode { public get methods(): PhpMethod[] { return this.searchAst(NodeKind.Method).map(ast => new PhpMethod(ast, this)); } + + public get extends(): string | undefined { + if (!this.ast.extends) { + return; + } + + return this.getIdentifierName(this.ast.extends); + } + + public get implements(): string[] { + return this.ast.implements?.map(impl => this.getIdentifierName(impl)) ?? []; + } } diff --git a/src/parser/php/PhpFile.ts b/src/parser/php/PhpFile.ts index a239928..9490e3a 100644 --- a/src/parser/php/PhpFile.ts +++ b/src/parser/php/PhpFile.ts @@ -36,4 +36,8 @@ export class PhpFile extends PhpNode { public get useItems() { return this.searchAst(NodeKind.UseItem).map(ast => new PhpUseItem(ast, this)); } + + public get comments(): string[] { + return this.ast.comments?.map(comment => comment.value) ?? []; + } } diff --git a/src/parser/php/PhpInterface.ts b/src/parser/php/PhpInterface.ts index 6988ab1..ad228c1 100644 --- a/src/parser/php/PhpInterface.ts +++ b/src/parser/php/PhpInterface.ts @@ -23,4 +23,8 @@ export class PhpInterface extends PhpNode { public get methods(): PhpMethod[] { return this.searchAst(NodeKind.Method).map(ast => new PhpMethod(ast, this)); } + + public get extends(): string[] { + return this.ast.extends?.map(ext => this.getIdentifierName(ext)) ?? []; + } } diff --git a/src/util/Common.ts b/src/util/Common.ts index 1ed3991..b711fea 100644 --- a/src/util/Common.ts +++ b/src/util/Common.ts @@ -1,3 +1,5 @@ +import { workspace, WorkspaceFolder, window } from 'vscode'; + export default class Common { private static isDev = process.env.NODE_ENV !== 'production'; @@ -16,4 +18,28 @@ export default class Common { console.timeEnd(name); } + + public static log(...message: string[]) { + if (!this.isDev) { + return; + } + + console.log(...message); + } + + public static getActiveWorkspaceFolder(): WorkspaceFolder | undefined { + if (!workspace.workspaceFolders) { + throw new Error('Workspace is empty'); + } + + if (workspace.workspaceFolders.length === 1) { + return workspace.workspaceFolders[0]; + } + + if (!window.activeTextEditor?.document.uri) { + throw new Error('No active text editor'); + } + + return workspace.getWorkspaceFolder(window.activeTextEditor.document.uri); + } } diff --git a/src/util/FileSystem.ts b/src/util/FileSystem.ts index aadc44d..a522a77 100644 --- a/src/util/FileSystem.ts +++ b/src/util/FileSystem.ts @@ -17,4 +17,8 @@ export default class FileSystem { const content = await workspace.fs.readFile(uri); return content.toString(); } + + public static async writeFile(uri: Uri, content: string): Promise { + await workspace.fs.writeFile(uri, Buffer.from(content)); + } } diff --git a/src/util/Position.ts b/src/util/Position.ts index 57a62f6..5ba17fe 100644 --- a/src/util/Position.ts +++ b/src/util/Position.ts @@ -1,8 +1,15 @@ -import { Position as PhpAstPosition } from 'php-parser'; -import { Position as VsCodePosition } from 'vscode'; +import { Position as PhpAstPosition, Location as PhpAstLocation } from 'php-parser'; +import { Position as VsCodePosition, Range } from 'vscode'; export default class Position { public static phpAstPositionToVsCodePosition(phpAstPosition: PhpAstPosition): VsCodePosition { return new VsCodePosition(Math.max(phpAstPosition.line - 1, 0), phpAstPosition.column); } + + public static phpAstLocationToVsCodeRange(phpAstLocation: PhpAstLocation): Range { + return new Range( + this.phpAstPositionToVsCodePosition(phpAstLocation.start), + this.phpAstPositionToVsCodePosition(phpAstLocation.end) + ); + } } diff --git a/src/webview/GeneratorWizard.ts b/src/webview/GeneratorWizard.ts index 165699d..66985e2 100644 --- a/src/webview/GeneratorWizard.ts +++ b/src/webview/GeneratorWizard.ts @@ -3,11 +3,13 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { Command, Message, Wizard } from './types'; import ExtensionState from 'common/ExtensionState'; +import WizzardClosedError from './error/WizzardClosedError'; export class GeneratorWizard extends Webview { protected async openWizard(pageData: Wizard): Promise { let it: NodeJS.Timeout; let loaded = false; + let completed = false; return new Promise((resolve, reject) => { this.open( @@ -30,11 +32,19 @@ export class GeneratorWizard extends Webview { } if (message.command === Command.Submit) { + completed = true; this.panel?.dispose(); resolve(message.data); } }); + this.panel?.onDidDispose(() => { + if (!completed) { + clearInterval(it); + reject(new WizzardClosedError()); + } + }); + it = setTimeout(() => { if (!loaded) { this.panel?.dispose(); diff --git a/src/webview/components/Wizard/Renderer.tsx b/src/webview/components/Wizard/Renderer.tsx index d396e07..e663a38 100644 --- a/src/webview/components/Wizard/Renderer.tsx +++ b/src/webview/components/Wizard/Renderer.tsx @@ -27,6 +27,7 @@ export const Renderer: React.FC = ({ wizard, vscode }) => { const handleValidation = useCallback((values: FormikValues) => { if (wizard.validation) { + console.log(wizard.validation); const validation = new Validator(values, wizard.validation, wizard.validationMessages); validation.passes(); diff --git a/src/webview/error/WizzardClosedError.ts b/src/webview/error/WizzardClosedError.ts new file mode 100644 index 0000000..c1dbe0d --- /dev/null +++ b/src/webview/error/WizzardClosedError.ts @@ -0,0 +1 @@ +export default class WizzardClosedError extends Error {} diff --git a/src/wizard/ModuleWizard.ts b/src/wizard/ModuleWizard.ts index 3757b37..73aa941 100644 --- a/src/wizard/ModuleWizard.ts +++ b/src/wizard/ModuleWizard.ts @@ -1,5 +1,5 @@ -import IndexStorage from 'common/IndexStorage'; -import ModuleIndexer from 'indexer/ModuleIndexer'; +import IndexManager from 'indexer/IndexManager'; +import ModuleIndexer from 'indexer/module/ModuleIndexer'; import { License } from 'types'; import { GeneratorWizard } from 'webview/GeneratorWizard'; import { WizardFieldBuilder } from 'webview/WizardFieldBuilder'; @@ -27,8 +27,13 @@ export interface ModuleWizardComposerData extends ModuleWizardBaseData { export default class ModuleWizard extends GeneratorWizard { public async show(): Promise { - const moduleNameIndex = IndexStorage.get(ModuleIndexer.KEY); - const modules = moduleNameIndex?.getModuleOptions() ?? []; + const moduleIndexData = IndexManager.getIndexData(ModuleIndexer.KEY); + + if (!moduleIndexData) { + throw new Error('Module index data not found'); + } + + const modules = moduleIndexData.getModuleOptions(); const builder = new WizardFormBuilder(); diff --git a/src/wizard/ObserverWizard.ts b/src/wizard/ObserverWizard.ts new file mode 100644 index 0000000..bda13c4 --- /dev/null +++ b/src/wizard/ObserverWizard.ts @@ -0,0 +1,87 @@ +import Validation from 'common/Validation'; +import IndexManager from 'indexer/IndexManager'; +import ModuleIndexer from 'indexer/module/ModuleIndexer'; +import { MagentoScope } from 'types'; +import { GeneratorWizard } from 'webview/GeneratorWizard'; +import { WizardFieldBuilder } from 'webview/WizardFieldBuilder'; +import { WizardFormBuilder } from 'webview/WizardFormBuilder'; + +export interface ObserverWizardData { + module: string; + area: MagentoScope; + eventName: string; + observerName: string; + className: string; + directoryPath: string; +} + +export default class ObserverWizard extends GeneratorWizard { + public async show(initialEventName?: string): Promise { + const moduleIndexData = IndexManager.getIndexData(ModuleIndexer.KEY); + + if (!moduleIndexData) { + throw new Error('Module index data not found'); + } + + const modules = moduleIndexData.getModuleOptions(module => module.location === 'app'); + + const builder = new WizardFormBuilder(); + + builder.setTitle('Generate a new observer'); + builder.setDescription('Generates a new observer.'); + + builder.addField( + WizardFieldBuilder.select('module', 'Module') + .setDescription(['Module where observer will be generated in']) + .setOptions(modules) + .setInitialValue(modules[0].value) + .build() + ); + + builder.addField( + WizardFieldBuilder.select('area', 'Area') + .setOptions(Object.values(MagentoScope).map(scope => ({ label: scope, value: scope }))) + .setInitialValue(MagentoScope.Global) + .build() + ); + + builder.addField( + WizardFieldBuilder.text('eventName', 'Event name') + .setPlaceholder('event_name') + .setInitialValue(initialEventName) + .build() + ); + builder.addField( + WizardFieldBuilder.text('observerName', 'Observer name') + .setPlaceholder('observer_name') + .build() + ); + + builder.addField( + WizardFieldBuilder.text('className', 'Observer class name') + .setPlaceholder('ObserverName') + .build() + ); + + builder.addField( + WizardFieldBuilder.text('directoryPath', 'Directory path').setInitialValue('Observer').build() + ); + + builder.addValidation('module', 'required'); + builder.addValidation('area', 'required'); + builder.addValidation('eventName', [ + 'required', + `regex:/${Validation.SNAKE_CASE_REGEX.source}/`, + ]); + builder.addValidation('observerName', ['required']); + builder.addValidation('className', [ + 'required', + `regex:/${Validation.CLASS_NAME_REGEX.source}/`, + ]); + builder.addValidation('directoryPath', 'required'); + + const data = await this.openWizard(builder.build()); + + return data; + } +} diff --git a/src/wizard/PluginContextWizard.ts b/src/wizard/PluginContextWizard.ts index d971a14..bb64218 100644 --- a/src/wizard/PluginContextWizard.ts +++ b/src/wizard/PluginContextWizard.ts @@ -1,5 +1,5 @@ -import IndexStorage from 'common/IndexStorage'; -import ModuleIndexer from 'indexer/ModuleIndexer'; +import IndexManager from 'indexer/IndexManager'; +import ModuleIndexer from 'indexer/module/ModuleIndexer'; import { MagentoScope } from 'types'; import { GeneratorWizard } from 'webview/GeneratorWizard'; import { WizardFieldBuilder } from 'webview/WizardFieldBuilder'; @@ -20,8 +20,13 @@ export default class PluginContextWizard extends GeneratorWizard { allowedMethods: string[], initialMethod?: string ): Promise { - const moduleNameIndex = IndexStorage.get(ModuleIndexer.KEY); - const modules = moduleNameIndex?.getModuleOptions(module => module.location === 'app') ?? []; + const moduleIndexData = IndexManager.getIndexData(ModuleIndexer.KEY); + + if (!moduleIndexData) { + throw new Error('Module index not found'); + } + + const modules = moduleIndexData.getModuleOptions(module => module.location === 'app'); const builder = new WizardFormBuilder(); builder.setTitle('Generate a new plugin'); diff --git a/templates/xml/blank-events.ejs b/templates/xml/blank-events.ejs new file mode 100644 index 0000000..4a2bf96 --- /dev/null +++ b/templates/xml/blank-events.ejs @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/templates/xml/observer.ejs b/templates/xml/observer.ejs new file mode 100644 index 0000000..2c61f19 --- /dev/null +++ b/templates/xml/observer.ejs @@ -0,0 +1,3 @@ + + + \ No newline at end of file