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