diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f1bd6..3b52498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the "magento-toolbox" extension will be documented in thi Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## [Unreleased] +- Added: Class namespace autocomplete in XML files +- Added: Module name autocomplete in module.xml files +- Added: Added extension config fields for enabling/disabling completions, definitions and hovers +- Added: Index data persistance +- Changed: Adjusted namespace indexer logic + ## [1.4.0] - 2025-04-04 - Added: Generator command for a ViewModel class - Added: Generator command for data patches diff --git a/package.json b/package.json index 0ca0485..a9a550d 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,20 @@ "type": "git", "url": "https://github.com/magebitcom/magento-toolbox.git" }, + "homepage": "https://github.com/magebitcom/magento-toolbox", + "bugs": { + "url": "https://github.com/magebitcom/magento-toolbox/issues" + }, "categories": [ "Other" ], + "keywords": [ + "magento", + "adobe commerce", + "code completion", + "code generation", + "intellisense" + ], "activationEvents": [ "workspaceContains:**/app/etc/env.php", "workspaceContains:**/app/etc/di.xml", @@ -41,6 +52,21 @@ "editPresentation": "multilineText", "default": "", "markdownDescription": "`%module%` will be replaced with the module name. \n\n **Do not add comment symbols like ``, they will be added automatically.**" + }, + "magento-toolbox.provideXmlCompletions": { + "type": "boolean", + "default": true, + "description": "Enable autocomplete for Magento 2 XML files." + }, + "magento-toolbox.provideXmlDefinitions": { + "type": "boolean", + "default": true, + "description": "Enable definitions for Magento 2 XML files." + }, + "magento-toolbox.provideXmlHovers": { + "type": "boolean", + "default": true, + "description": "Enable hover decorations for Magento 2 XML files." } } }, diff --git a/src/command/IndexWorkspaceCommand.ts b/src/command/IndexWorkspaceCommand.ts index abe21d4..5c95e8d 100644 --- a/src/command/IndexWorkspaceCommand.ts +++ b/src/command/IndexWorkspaceCommand.ts @@ -7,6 +7,6 @@ export default class IndexWorkspaceCommand extends Command { } public async execute(...args: any[]): Promise { - await IndexRunner.indexWorkspace(); + await IndexRunner.indexWorkspace(true); } } diff --git a/src/common/Config.ts b/src/common/Config.ts new file mode 100644 index 0000000..b12394c --- /dev/null +++ b/src/common/Config.ts @@ -0,0 +1,11 @@ +import { workspace } from 'vscode'; + +class Config { + public readonly SECTION = 'magento-toolbox'; + + public get(key: string): T | undefined { + return workspace.getConfiguration(this.SECTION).get(key); + } +} + +export default new Config(); diff --git a/src/common/PhpNamespace.ts b/src/common/PhpNamespace.ts index b488c67..5e6bdbd 100644 --- a/src/common/PhpNamespace.ts +++ b/src/common/PhpNamespace.ts @@ -17,10 +17,22 @@ export default class PhpNamespace { return new PhpNamespace(parts); } + public pop(): string { + return this.parts.pop() as string; + } + public getParts(): string[] { return this.parts; } + public getHead(): string { + return this.parts[0]; + } + + public getTail(): string { + return this.parts[this.parts.length - 1]; + } + public toString(): string { return this.parts.join(PhpNamespace.NS_SEPARATOR); } diff --git a/src/common/php/FileHeader.ts b/src/common/php/FileHeader.ts index 669b574..003d791 100644 --- a/src/common/php/FileHeader.ts +++ b/src/common/php/FileHeader.ts @@ -1,10 +1,8 @@ -import { workspace } from 'vscode'; +import Config from 'common/Config'; export default class FileHeader { public static getHeader(module: string): string | undefined { - const header = workspace - .getConfiguration('magento-toolbox') - .get('phpFileHeaderComment'); + const header = Config.get('phpFileHeaderComment'); if (!header) { return undefined; diff --git a/src/common/xml/FileHeader.ts b/src/common/xml/FileHeader.ts index 16067f2..11c5480 100644 --- a/src/common/xml/FileHeader.ts +++ b/src/common/xml/FileHeader.ts @@ -1,10 +1,8 @@ -import { workspace } from 'vscode'; +import Config from 'common/Config'; export default class FileHeader { public static getHeader(module: string): string | undefined { - const header = workspace - .getConfiguration('magento-toolbox') - .get('xmlFileHeaderComment'); + const header = Config.get('xmlFileHeaderComment'); if (!header) { return undefined; diff --git a/src/common/xml/XmlDocumentParser.ts b/src/common/xml/XmlDocumentParser.ts index 4dbfe1f..bc352d6 100644 --- a/src/common/xml/XmlDocumentParser.ts +++ b/src/common/xml/XmlDocumentParser.ts @@ -18,17 +18,21 @@ class XmlDocumentParser { this.parser = new PhpParser(); } - public async parse(document: TextDocument): Promise { + public async parse(document: TextDocument, skipCache = false): Promise { const cacheKey = `xml-file`; - if (DocumentCache.has(document, cacheKey)) { + if (!skipCache && DocumentCache.has(document, cacheKey)) { return DocumentCache.get(document, cacheKey); } const { cst, tokenVector } = parse(document.getText()); const ast = buildAst(cst as DocumentCstNode, tokenVector); const tokenData: TokenData = { cst: cst as DocumentCstNode, tokenVector, ast }; - DocumentCache.set(document, cacheKey, tokenData); + + if (!skipCache) { + DocumentCache.set(document, cacheKey, tokenData); + } + return tokenData; } } diff --git a/src/completion/XmlCompletionProvider.ts b/src/completion/XmlCompletionProvider.ts new file mode 100644 index 0000000..ab06965 --- /dev/null +++ b/src/completion/XmlCompletionProvider.ts @@ -0,0 +1,57 @@ +import { minimatch } from 'minimatch'; +import { + CancellationToken, + CompletionItem, + CompletionItemProvider, + CompletionList, + Position, + TextDocument, + Range, +} from 'vscode'; +import { getSuggestions, SuggestionProviders } from '@xml-tools/content-assist'; +import XmlDocumentParser, { TokenData } from 'common/xml/XmlDocumentParser'; +import Config from 'common/Config'; +import { ModuleCompletionItemProvider } from './xml/ModuleCompletionItemProvider'; +import { NamespaceCompletionItemProvider } from './xml/NamespaceCompletionItemProvider'; +import { XmlCompletionItemProvider } from './xml/XmlCompletionItemProvider'; + +export class XmlCompletionProvider implements CompletionItemProvider { + private readonly providers: XmlCompletionItemProvider[]; + + public constructor() { + this.providers = [new ModuleCompletionItemProvider(), new NamespaceCompletionItemProvider()]; + } + + public async provideCompletionItems( + document: TextDocument, + position: Position, + token: CancellationToken + ): Promise { + if (!this.providers.some(provider => provider.canProvideCompletion(document))) { + return []; + } + + const tokenData = await XmlDocumentParser.parse(document, true); + + const providerCompletionItems = await Promise.all( + this.providers.map(provider => + this.getProviderCompletionItems(provider, document, position, tokenData) + ) + ); + + return providerCompletionItems.flat(); + } + + private async getProviderCompletionItems( + provider: XmlCompletionItemProvider, + document: TextDocument, + position: Position, + tokenData: TokenData + ): Promise { + if (!provider.canProvideCompletion(document)) { + return []; + } + + return provider.getCompletions(document, position, tokenData); + } +} diff --git a/src/completion/xml/ModuleCompletionItemProvider.ts b/src/completion/xml/ModuleCompletionItemProvider.ts new file mode 100644 index 0000000..78bfed0 --- /dev/null +++ b/src/completion/xml/ModuleCompletionItemProvider.ts @@ -0,0 +1,51 @@ +import { CompletionItem, CompletionItemKind } from 'vscode'; +import { SuggestionProviders } from '@xml-tools/content-assist'; +import IndexManager from 'indexer/IndexManager'; +import ModuleIndexer from 'indexer/module/ModuleIndexer'; +import { XMLElement, XMLAttribute } from '@xml-tools/ast'; +import { XmlCompletionItemProvider } from './XmlCompletionItemProvider'; + +export class ModuleCompletionItemProvider extends XmlCompletionItemProvider { + getFilePatterns(): string[] { + return ['**/etc/module.xml']; + } + + getCompletionProviders(): SuggestionProviders { + return { + attributeValue: [this.getAttributeValueCompletions.bind(this)], + }; + } + + private getAttributeValueCompletions({ + element, + attribute, + }: { + element: XMLElement; + attribute: XMLAttribute; + }): CompletionItem[] { + if ( + element.name !== 'module' || + (element.parent as XMLElement)?.name !== 'sequence' || + attribute.key !== 'name' + ) { + return []; + } + + const value = attribute?.value || ''; + return this.getCompletionItems(value); + } + + private getCompletionItems(prefix: string): CompletionItem[] { + const moduleIndexData = IndexManager.getIndexData(ModuleIndexer.KEY); + + if (!moduleIndexData) { + return []; + } + + const completions = moduleIndexData.getModulesByPrefix(prefix); + + return completions.map(module => { + return new CompletionItem(module.name, CompletionItemKind.Value); + }); + } +} diff --git a/src/completion/xml/NamespaceCompletionItemProvider.ts b/src/completion/xml/NamespaceCompletionItemProvider.ts new file mode 100644 index 0000000..fe2b624 --- /dev/null +++ b/src/completion/xml/NamespaceCompletionItemProvider.ts @@ -0,0 +1,159 @@ +import { CompletionItem, CompletionItemKind } from 'vscode'; +import { SuggestionProviders } from '@xml-tools/content-assist'; +import IndexManager from 'indexer/IndexManager'; +import AutoloadNamespaceIndexer from 'indexer/autoload-namespace/AutoloadNamespaceIndexer'; +import { XMLElement, XMLAttribute } from '@xml-tools/ast'; +import { XmlCompletionItemProvider } from './XmlCompletionItemProvider'; + +interface AttributeValueMatch { + element?: string; + attributeName: string; +} + +interface ElementContentMatch { + element?: string; + attributeName?: string; + attributeValue?: string; +} + +export class NamespaceCompletionItemProvider extends XmlCompletionItemProvider { + private static readonly ATTRIBUTE_VALUE_MATCHERS: AttributeValueMatch[] = [ + { + element: 'preference', + attributeName: 'for', + }, + { + element: 'preference', + attributeName: 'type', + }, + { + element: 'type', + attributeName: 'name', + }, + { + element: 'plugin', + attributeName: 'type', + }, + { + element: 'virtualType', + attributeName: 'type', + }, + { + attributeName: 'instance', + }, + { + attributeName: 'class', + }, + { + element: 'attribute', + attributeName: 'type', + }, + { + element: 'extension_attributes', + attributeName: 'for', + }, + { + element: 'consumer', + attributeName: 'handler', + }, + { + element: 'queue', + attributeName: 'handler', + }, + { + element: 'handler', + attributeName: 'type', + }, + ]; + + private static readonly ELEMENT_CONTENT_MATCHERS: ElementContentMatch[] = [ + { + attributeName: 'xsi:type', + attributeValue: 'object', + }, + { + element: 'backend_model', + }, + { + element: 'frontend_model', + }, + { + element: 'source_model', + }, + ]; + + getFilePatterns(): string[] { + return ['**/etc/**/*.xml']; + } + + getCompletionProviders(): SuggestionProviders { + return { + attributeValue: [this.getAttributeValueCompletions.bind(this)], + elementContent: [this.getElementContentCompletions.bind(this)], + }; + } + + private getAttributeValueCompletions({ + element, + attribute, + }: { + element: XMLElement; + attribute: XMLAttribute; + }): CompletionItem[] { + const match = NamespaceCompletionItemProvider.ATTRIBUTE_VALUE_MATCHERS.find(matchElement => { + if (matchElement.element && matchElement.element !== element.name) { + return false; + } + + return matchElement.attributeName === attribute.key; + }); + + if (!match) { + return []; + } + + const value = attribute?.value || ''; + return this.getCompletionItems(value); + } + + private getElementContentCompletions({ element }: { element: XMLElement }): CompletionItem[] { + const match = NamespaceCompletionItemProvider.ELEMENT_CONTENT_MATCHERS.find(matchElement => { + if (matchElement.element && matchElement.element !== element.name) { + return false; + } + + if (matchElement.attributeName && matchElement.attributeValue) { + return element.attributes.some( + attribute => + attribute.key === matchElement.attributeName && + attribute.value === matchElement.attributeValue + ); + } + + return true; + }); + + if (!match) { + return []; + } + + const elementContent = + element.textContents.length > 0 ? (element.textContents[0].text ?? '') : ''; + + return this.getCompletionItems(elementContent); + } + + private getCompletionItems(prefix: string): CompletionItem[] { + const namespaceIndexData = IndexManager.getIndexData(AutoloadNamespaceIndexer.KEY); + + if (!namespaceIndexData) { + return []; + } + + const completions = namespaceIndexData.findNamespacesByPrefix(prefix); + + return completions.map(namespace => { + return new CompletionItem(namespace.fqn, CompletionItemKind.Value); + }); + } +} diff --git a/src/completion/xml/XmlCompletionItemProvider.ts b/src/completion/xml/XmlCompletionItemProvider.ts new file mode 100644 index 0000000..4cc00ca --- /dev/null +++ b/src/completion/xml/XmlCompletionItemProvider.ts @@ -0,0 +1,59 @@ +import { minimatch } from 'minimatch'; +import { CompletionItem, Position, TextDocument, Range } from 'vscode'; +import { getSuggestions, SuggestionProviders } from '@xml-tools/content-assist'; +import { TokenData } from 'common/xml/XmlDocumentParser'; +import Config from 'common/Config'; + +export abstract class XmlCompletionItemProvider { + public abstract getFilePatterns(): string[]; + public abstract getCompletionProviders(): SuggestionProviders; + + public async getCompletions( + document: TextDocument, + position: Position, + tokenData: TokenData + ): Promise { + const offset = document.offsetAt(position); + + const completions = getSuggestions({ + ...tokenData, + offset, + providers: this.getCompletionProviders(), + }); + + return this.fixCompletionItemRanges(document, position, completions); + } + + private fixCompletionItemRanges( + document: TextDocument, + position: Position, + completions: CompletionItem[] + ): CompletionItem[] { + const range = document.getWordRangeAtPosition(position, /("[^"]+")|(>[^<]+<)/); + + if (range) { + const rangeWithoutQuotes = new Range( + range.start.with({ character: range.start.character + 1 }), + range.end.with({ character: range.end.character - 1 }) + ); + + for (const completion of completions) { + completion.range = rangeWithoutQuotes; + } + } + + return completions; + } + + public canProvideCompletion(document: TextDocument): boolean { + const provideXmlCompletions = Config.get('provideXmlCompletions'); + + if (!provideXmlCompletions) { + return false; + } + + return this.getFilePatterns().some(pattern => + minimatch(document.uri.fsPath, pattern, { matchBase: true }) + ); + } +} diff --git a/src/decorator/PluginClassDecorationProvider.ts b/src/decorator/PluginClassDecorationProvider.ts index 6b9aa7b..9aed758 100644 --- a/src/decorator/PluginClassDecorationProvider.ts +++ b/src/decorator/PluginClassDecorationProvider.ts @@ -76,7 +76,7 @@ export default class PluginClassDecorationProvider extends TextDocumentDecoratio const methodPlugins: Record = {}; for (const plugin of classPlugins) { - const fileUri = await namespaceIndexData.findClassByNamespace( + const fileUri = await namespaceIndexData.findUriByNamespace( PhpNamespace.fromString(plugin.type) ); @@ -135,7 +135,7 @@ export default class PluginClassDecorationProvider extends TextDocumentDecoratio const message = MarkdownMessageBuilder.create('Interceptors'); for (const interceptor of classInterceptors) { - const fileUri = await namespaceIndexData.findClassByNamespace( + const fileUri = await namespaceIndexData.findUriByNamespace( PhpNamespace.fromString(interceptor.type) ); diff --git a/src/definition/XmlClasslikeDefinitionProvider.ts b/src/definition/XmlClasslikeDefinitionProvider.ts index 4cb9e31..35f36f6 100644 --- a/src/definition/XmlClasslikeDefinitionProvider.ts +++ b/src/definition/XmlClasslikeDefinitionProvider.ts @@ -1,3 +1,4 @@ +import Config from 'common/Config'; import { ClasslikeInfo } from 'common/php/ClasslikeInfo'; import PhpDocumentParser from 'common/php/PhpDocumentParser'; import PhpNamespace from 'common/PhpNamespace'; @@ -19,6 +20,12 @@ export class XmlClasslikeDefinitionProvider implements DefinitionProvider { position: Position, token: CancellationToken ) { + const provideXmlDefinitions = Config.get('provideXmlDefinitions'); + + if (!provideXmlDefinitions) { + return null; + } + const range = document.getWordRangeAtPosition(position, /("[^"]+")|(>[^<]+<)/); if (!range) { @@ -33,9 +40,14 @@ export class XmlClasslikeDefinitionProvider implements DefinitionProvider { return null; } - const potentialNamespace = word.replace(/["<>]/g, ''); + // also handle constants + const potentialNamespace = word.replace(/["<>]/g, '').split(':').shift(); + + if (!potentialNamespace) { + return null; + } - const classUri = await namespaceIndexData.findClassByNamespace( + const classUri = await namespaceIndexData.findUriByNamespace( PhpNamespace.fromString(potentialNamespace) ); diff --git a/src/definition/XmlDefinitionProvider.ts b/src/definition/XmlDefinitionProvider.ts index 908da4c..3ccf11b 100644 --- a/src/definition/XmlDefinitionProvider.ts +++ b/src/definition/XmlDefinitionProvider.ts @@ -8,6 +8,7 @@ import { } from 'vscode'; import { getSuggestions, SuggestionProviders } from '@xml-tools/content-assist'; import XmlDocumentParser from 'common/xml/XmlDocumentParser'; +import Config from 'common/Config'; export abstract class XmlDefinitionProvider implements DefinitionProvider { public abstract getFilePatterns(): string[]; @@ -36,6 +37,12 @@ export abstract class XmlDefinitionProvider implements DefinitionProvider { } private canProvideDefinition(document: TextDocument): boolean { + const provideXmlDefinitions = Config.get('provideXmlDefinitions'); + + if (!provideXmlDefinitions) { + return false; + } + return this.getFilePatterns().some(pattern => minimatch(document.uri.fsPath, pattern, { matchBase: true }) ); diff --git a/src/extension.ts b/src/extension.ts index 54928c6..408175f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,6 +14,7 @@ import { WorkspaceFolder } from 'vscode'; import Logger from 'util/Logger'; import { Command } from 'command/Command'; import { XmlModuleDefinitionProvider } from 'definition/XmlModuleDefinitionProvider'; +import { XmlCompletionProvider } from 'completion/XmlCompletionProvider'; // This method is called when your extension is activated // Your extension is activated the very first time the command is executed @@ -100,6 +101,15 @@ export async function activate(context: vscode.ExtensionContext) { vscode.languages.registerCodeLensProvider('php', new ObserverCodelensProvider()) ); + // completion providers + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + { language: 'xml', scheme: 'file' }, + new XmlCompletionProvider(), + ...'\\"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + ) + ); + // hover providers context.subscriptions.push( vscode.languages.registerHoverProvider('xml', new XmlClasslikeHoverProvider()) diff --git a/src/hover/XmlClasslikeHoverProvider.ts b/src/hover/XmlClasslikeHoverProvider.ts index 735bbef..7702d4d 100644 --- a/src/hover/XmlClasslikeHoverProvider.ts +++ b/src/hover/XmlClasslikeHoverProvider.ts @@ -1,3 +1,4 @@ +import Config from 'common/Config'; import { ClasslikeInfo } from 'common/php/ClasslikeInfo'; import PhpDocumentParser from 'common/php/PhpDocumentParser'; import PhpNamespace from 'common/PhpNamespace'; @@ -7,6 +8,12 @@ import { Hover, HoverProvider, Position, Range, TextDocument } from 'vscode'; export default class XmlClasslikeHoverProvider implements HoverProvider { public async provideHover(document: TextDocument, position: Position): Promise { + const provideXmlHovers = Config.get('provideXmlHovers'); + + if (!provideXmlHovers) { + return null; + } + const range = document.getWordRangeAtPosition(position, /("[^"]+")|(>[^<]+<)/); if (!range) { @@ -21,9 +28,13 @@ export default class XmlClasslikeHoverProvider implements HoverProvider { return null; } - const potentialNamespace = word.replace(/["<>]/g, ''); + const potentialNamespace = word.replace(/["<>]/g, '').split(':').shift(); + + if (!potentialNamespace) { + return null; + } - const classUri = await namespaceIndexData.findClassByNamespace( + const classUri = await namespaceIndexData.findUriByNamespace( PhpNamespace.fromString(potentialNamespace) ); diff --git a/src/indexer/AbstractIndexData.ts b/src/indexer/AbstractIndexData.ts index 2920156..46bc2f9 100644 --- a/src/indexer/AbstractIndexData.ts +++ b/src/indexer/AbstractIndexData.ts @@ -1,7 +1,8 @@ import { Memoize } from 'typescript-memoize'; +import { IndexedFilePath } from 'types/indexer'; export abstract class AbstractIndexData { - public constructor(protected data: Map) {} + public constructor(protected data: Map) {} @Memoize() public getValues(): T[] { diff --git a/src/indexer/IndexDataSerializer.ts b/src/indexer/IndexDataSerializer.ts new file mode 100644 index 0000000..0d5fa49 --- /dev/null +++ b/src/indexer/IndexDataSerializer.ts @@ -0,0 +1,25 @@ +import { SavedIndex } from 'types/indexer'; + +export class IndexDataSerializer { + public serialize(data: SavedIndex): string { + return JSON.stringify(data, this.replacer); + } + + public deserialize(data: string): SavedIndex { + return JSON.parse(data, this.reviver); + } + + private replacer(key: string, value: any) { + if (value instanceof Map) { + return { __type: 'Map', value: Array.from(value.entries()) }; + } + return value; + } + + private reviver(key: string, value: any) { + if (value && typeof value === 'object' && value.__type === 'Map') { + return new Map(value.value); + } + return value; + } +} diff --git a/src/indexer/IndexManager.ts b/src/indexer/IndexManager.ts index 14bf6f2..a890259 100644 --- a/src/indexer/IndexManager.ts +++ b/src/indexer/IndexManager.ts @@ -13,6 +13,7 @@ import { ModuleIndexData } from './module/ModuleIndexData'; import { AutoloadNamespaceIndexData } from './autoload-namespace/AutoloadNamespaceIndexData'; import { EventsIndexData } from './events/EventsIndexData'; import Logger from 'util/Logger'; +import { IndexerKey } from 'types/indexer'; type IndexerInstance = DiIndexer | ModuleIndexer | AutoloadNamespaceIndexer | EventsIndexer; @@ -55,7 +56,10 @@ class IndexManager { Logger.logWithTime('Indexing workspace', workspaceFolder.name); for (const indexer of this.indexers) { - if (!force && !this.shouldIndex(indexer)) { + this.indexStorage.loadIndex(workspaceFolder, indexer.getId(), indexer.getVersion()); + + if (!force && !this.shouldIndex(workspaceFolder, indexer)) { + Logger.logWithTime('Loaded index from storage', workspaceFolder.name, indexer.getId()); continue; } progress.report({ message: `Indexing - ${indexer.getName()}`, increment: 0 }); @@ -87,6 +91,7 @@ class IndexManager { ); this.indexStorage.set(workspaceFolder, indexer.getId(), indexData); + this.indexStorage.saveIndex(workspaceFolder, indexer.getId(), indexer.getVersion()); clear([indexer.getId()]); @@ -121,7 +126,7 @@ class IndexManager { } public getIndexStorageData( - id: string, + id: IndexerKey, workspaceFolder?: WorkspaceFolder ): Map | undefined { const wf = workspaceFolder || Common.getActiveWorkspaceFolder(); @@ -182,8 +187,8 @@ class IndexManager { clear([indexer.getId()]); } - protected shouldIndex(index: IndexerInstance): boolean { - return true; + protected shouldIndex(workspaceFolder: WorkspaceFolder, index: IndexerInstance): boolean { + return !this.indexStorage.hasIndex(workspaceFolder, index.getId()); } } diff --git a/src/indexer/IndexRunner.ts b/src/indexer/IndexRunner.ts index dae4b5b..87348b3 100644 --- a/src/indexer/IndexRunner.ts +++ b/src/indexer/IndexRunner.ts @@ -14,7 +14,7 @@ class IndexRunner { title: '[Magento Toolbox]', }, async progress => { - await IndexManager.indexWorkspace(workspaceFolder, progress); + await IndexManager.indexWorkspace(workspaceFolder, progress, force); } ); } diff --git a/src/indexer/IndexStorage.ts b/src/indexer/IndexStorage.ts index b9da962..1b215da 100644 --- a/src/indexer/IndexStorage.ts +++ b/src/indexer/IndexStorage.ts @@ -1,11 +1,14 @@ import { WorkspaceFolder } from 'vscode'; - -export type IndexerStorage = Record>>; +import { IndexerKey, IndexerStorage, IndexedFilePath, SavedIndex } from 'types/indexer'; +import { IndexDataSerializer } from './IndexDataSerializer'; +import Context from 'common/Context'; +import ExtensionState from 'common/ExtensionState'; export default class IndexStorage { private _indexStorage: IndexerStorage = {}; + private serializer = new IndexDataSerializer(); - public set(workspaceFolder: WorkspaceFolder, key: string, value: any) { + public set(workspaceFolder: WorkspaceFolder, key: IndexerKey, value: Map) { if (!this._indexStorage[workspaceFolder.uri.fsPath]) { this._indexStorage[workspaceFolder.uri.fsPath] = {}; } @@ -13,7 +16,10 @@ export default class IndexStorage { this._indexStorage[workspaceFolder.uri.fsPath][key] = value; } - public get(workspaceFolder: WorkspaceFolder, key: string): Map | undefined { + public get( + workspaceFolder: WorkspaceFolder, + key: IndexerKey + ): Map | undefined { return this._indexStorage[workspaceFolder.uri.fsPath]?.[key]; } @@ -21,11 +27,44 @@ export default class IndexStorage { this._indexStorage = {}; } - public async load() { - // TODO: Implement + public hasIndex(workspaceFolder: WorkspaceFolder, key: IndexerKey) { + return !!this._indexStorage[workspaceFolder.uri.fsPath]?.[key]; + } + + public saveIndex(workspaceFolder: WorkspaceFolder, key: IndexerKey, version: number) { + const indexData = this._indexStorage[workspaceFolder.uri.fsPath][key]; + + const savedIndex: SavedIndex = { + version, + data: indexData, + }; + const serialized = this.serializer.serialize(savedIndex); + + ExtensionState.context.globalState.update( + `index-storage-${workspaceFolder.uri.fsPath}-${key}`, + serialized + ); } - public async save() { - // TODO: Implement + public loadIndex(workspaceFolder: WorkspaceFolder, key: IndexerKey, version: number) { + const serialized = ExtensionState.context.globalState.get( + `index-storage-${workspaceFolder.uri.fsPath}-${key}` + ); + + if (!serialized) { + return undefined; + } + + const savedIndex = this.serializer.deserialize(serialized); + + if (savedIndex.version !== version) { + return undefined; + } + + if (!this._indexStorage[workspaceFolder.uri.fsPath]) { + this._indexStorage[workspaceFolder.uri.fsPath] = {}; + } + + this._indexStorage[workspaceFolder.uri.fsPath][key] = savedIndex.data; } } diff --git a/src/indexer/Indexer.ts b/src/indexer/Indexer.ts index 7306207..e7afdd4 100644 --- a/src/indexer/Indexer.ts +++ b/src/indexer/Indexer.ts @@ -1,8 +1,10 @@ import { type GlobPattern, type Uri } from 'vscode'; +import { IndexerKey } from 'types/indexer'; export abstract class Indexer { - public abstract getId(): string; + public abstract getId(): IndexerKey; public abstract getName(): string; public abstract getPattern(uri: Uri): GlobPattern; public abstract indexFile(uri: Uri): Promise; + public abstract getVersion(): number; } diff --git a/src/indexer/autoload-namespace/AutoloadNamespaceIndexData.ts b/src/indexer/autoload-namespace/AutoloadNamespaceIndexData.ts index f09ecf1..21c02af 100644 --- a/src/indexer/autoload-namespace/AutoloadNamespaceIndexData.ts +++ b/src/indexer/autoload-namespace/AutoloadNamespaceIndexData.ts @@ -1,78 +1,42 @@ 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'; +import { Namespace } from './types'; -export class AutoloadNamespaceIndexData extends AbstractIndexData { +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; - } + public getNamespaces(): Namespace[] { + return Array.from(this.data.values()).flat(); + } - const classPath = classNamespace.toString().replace(/\\/g, '/'); - const fileUri = Uri.joinPath(directory, `${classPath}.php`); + @Memoize({ + tags: [AutoloadNamespaceIndexer.KEY], + hashFunction: (namespace: PhpNamespace) => namespace.toString(), + }) + public async findUriByNamespace(phpNamespace: PhpNamespace): Promise { + const namespaces = this.getNamespaces(); - return fileUri; + if (AutoloadNamespaceIndexData.SPECIAL_CLASSNAMES.includes(phpNamespace.getTail())) { + phpNamespace.pop(); } - return undefined; - } + const namespace = namespaces.find(n => n.fqn === phpNamespace.toString()); - private getDirectoriesByNamespace(namespace: string): string[] { - const namespaceData = this.getValues().filter(data => data[namespace] !== undefined); - - if (!namespaceData) { - return []; + if (!namespace) { + return undefined; } - return namespaceData.flatMap(data => data[namespace] ?? []); + return Uri.file(namespace.path); } - 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; + public findNamespacesByPrefix(prefix: string): Namespace[] { + const namespaces = this.getNamespaces(); + return namespaces.filter(namespace => namespace.fqn.startsWith(prefix)); } } diff --git a/src/indexer/autoload-namespace/AutoloadNamespaceIndexer.ts b/src/indexer/autoload-namespace/AutoloadNamespaceIndexer.ts index 7523a09..81ab2eb 100644 --- a/src/indexer/autoload-namespace/AutoloadNamespaceIndexer.ts +++ b/src/indexer/autoload-namespace/AutoloadNamespaceIndexer.ts @@ -1,12 +1,17 @@ import { RelativePattern, Uri } from 'vscode'; import { Indexer } from 'indexer/Indexer'; import FileSystem from 'util/FileSystem'; -import { AutoloadNamespaceData } from './types'; +import { Namespace } from './types'; +import { IndexerKey } from 'types/indexer'; -export default class AutoloadNamespaceIndexer extends Indexer { +export default class AutoloadNamespaceIndexer extends Indexer { public static readonly KEY = 'autoloadNamespace'; - public getId(): string { + public getVersion(): number { + return 1; + } + + public getId(): IndexerKey { return AutoloadNamespaceIndexer.KEY; } @@ -18,7 +23,7 @@ export default class AutoloadNamespaceIndexer extends Indexer { + public async indexFile(uri: Uri): Promise { const content = await FileSystem.readFile(uri); const composer = JSON.parse(content); @@ -27,34 +32,75 @@ export default class AutoloadNamespaceIndexer extends Indexer Uri.joinPath(baseDir, dir.replace(/^\.\//, '')).fsPath - ); - } + data.push(...namespaces); } - // Handle PSR-0 autoloading + // // 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 namespaces = await this.indexNamespaces(composer.autoload['psr-0'], baseDir); - data[this.normalizeNamespace(namespace)] = directories.map( - (dir: string) => Uri.joinPath(baseDir, dir.replace(/^\.\//, '')).fsPath - ); - } + data.push(...namespaces); } return data; } + private async indexNamespaces( + autoLoadData: Record, + baseDir: Uri + ): Promise { + const promises: Promise[] = []; + + for (const [namespace, paths] of Object.entries(autoLoadData)) { + const directories = Array.isArray(paths) ? paths : [paths]; + + for (const directory of directories) { + promises.push(this.expandNamespaces(namespace, baseDir, directory)); + } + } + + const namespaces = await Promise.all(promises); + return namespaces.flat(); + } + + private async expandNamespaces( + baseNamespace: string, + baseDirectory: Uri, + relativeBaseDirectory: string + ): Promise { + const baseDirectoryUri = Uri.joinPath(baseDirectory, relativeBaseDirectory.replace(/\\$/, '')); + const files = await FileSystem.readDirectoryRecursive(baseDirectoryUri); + + return files + .filter(file => file.endsWith('.php')) + .filter(file => { + const parts = file.split('/'); + const filename = parts[parts.length - 1]; + return filename.charAt(0) === filename.charAt(0).toUpperCase(); + }) + .map(file => { + const namespace = file.replace('.php', ''); + + const fqn = this.normalizeNamespace( + `${this.normalizeNamespace(baseNamespace)}\\${namespace.replace(/\//g, '\\')}` + ); + + return { + fqn, + prefix: baseNamespace, + baseDirectory: baseDirectoryUri.fsPath, + path: Uri.joinPath(baseDirectoryUri, file).fsPath, + }; + }); + } + private normalizeNamespace(namespace: string): string { - return namespace.replace(/\\$/, ''); + return namespace.replace(/\\$/, '').replace(/^\\/, ''); } } diff --git a/src/indexer/autoload-namespace/types.ts b/src/indexer/autoload-namespace/types.ts index d146239..a8f9c89 100644 --- a/src/indexer/autoload-namespace/types.ts +++ b/src/indexer/autoload-namespace/types.ts @@ -1 +1,6 @@ -export type AutoloadNamespaceData = Record; +export interface Namespace { + fqn: string; + prefix: string; + baseDirectory: string; + path: string; +} diff --git a/src/indexer/di/DiIndexer.ts b/src/indexer/di/DiIndexer.ts index 3909f8e..b0e61cb 100644 --- a/src/indexer/di/DiIndexer.ts +++ b/src/indexer/di/DiIndexer.ts @@ -4,6 +4,7 @@ import { get } from 'lodash-es'; import FileSystem from 'util/FileSystem'; import { DiData, DiPlugin, DiPreference, DiType, DiVirtualType } from './types'; import { Indexer } from 'indexer/Indexer'; +import { IndexerKey } from 'types/indexer'; export default class DiIndexer extends Indexer { public static readonly KEY = 'di'; @@ -29,7 +30,11 @@ export default class DiIndexer extends Indexer { }); } - public getId(): string { + public getVersion(): number { + return 1; + } + + public getId(): IndexerKey { return DiIndexer.KEY; } diff --git a/src/indexer/events/EventsIndexer.ts b/src/indexer/events/EventsIndexer.ts index 4d69212..22b7db6 100644 --- a/src/indexer/events/EventsIndexer.ts +++ b/src/indexer/events/EventsIndexer.ts @@ -3,6 +3,7 @@ import { XMLParser } from 'fast-xml-parser'; import { get } from 'lodash-es'; import { Indexer } from 'indexer/Indexer'; import FileSystem from 'util/FileSystem'; +import { IndexerKey } from 'types/indexer'; export default class EventsIndexer extends Indexer { public static readonly KEY = 'events'; @@ -21,7 +22,11 @@ export default class EventsIndexer extends Indexer { }); } - public getId(): string { + public getVersion(): number { + return 1; + } + + public getId(): IndexerKey { return EventsIndexer.KEY; } diff --git a/src/indexer/module/ModuleIndexData.ts b/src/indexer/module/ModuleIndexData.ts index 40d7c84..e598380 100644 --- a/src/indexer/module/ModuleIndexData.ts +++ b/src/indexer/module/ModuleIndexData.ts @@ -19,6 +19,10 @@ export class ModuleIndexData extends AbstractIndexData { ); } + public getModulesByPrefix(prefix: string): Module[] { + return this.getValues().filter(module => module.name.startsWith(prefix)); + } + public getModuleByUri(uri: Uri, appOnly = true): Module | undefined { const module = this.getValues().find(module => { return uri.fsPath.startsWith(module.path) && (!appOnly || module.location === 'app'); diff --git a/src/indexer/module/ModuleIndexer.ts b/src/indexer/module/ModuleIndexer.ts index 87b74e5..2f54471 100644 --- a/src/indexer/module/ModuleIndexer.ts +++ b/src/indexer/module/ModuleIndexer.ts @@ -4,6 +4,7 @@ import { get } from 'lodash-es'; import { Module } from './types'; import { Indexer } from 'indexer/Indexer'; import FileSystem from 'util/FileSystem'; +import { IndexerKey } from 'types/indexer'; export default class ModuleIndexer extends Indexer { public static readonly KEY = 'module'; @@ -22,7 +23,11 @@ export default class ModuleIndexer extends Indexer { }); } - public getId(): string { + public getVersion(): number { + return 1; + } + + public getId(): IndexerKey { return ModuleIndexer.KEY; } diff --git a/src/types/indexer.ts b/src/types/indexer.ts new file mode 100644 index 0000000..f542cc8 --- /dev/null +++ b/src/types/indexer.ts @@ -0,0 +1,13 @@ +export type IndexerKey = string; +export type IndexedFilePath = string; +type WorkspacePath = string; + +export type IndexerStorage = Record< + WorkspacePath, + Record> +>; + +export interface SavedIndex { + version: number; + data: Map; +} diff --git a/src/util/FileSystem.ts b/src/util/FileSystem.ts index 7480cf2..e0ab43b 100644 --- a/src/util/FileSystem.ts +++ b/src/util/FileSystem.ts @@ -1,4 +1,4 @@ -import { Uri, workspace } from 'vscode'; +import { FileType, RelativePattern, Uri, workspace } from 'vscode'; import * as path from 'path'; import ExtensionState from 'common/ExtensionState'; @@ -20,6 +20,16 @@ export default class FileSystem { return content.toString(); } + public static async readDirectory(uri: Uri): Promise { + const files = await workspace.fs.readDirectory(uri); + return files.map(([name]) => name); + } + + public static async readDirectoryRecursive(uri: Uri): Promise { + const files = await workspace.findFiles(new RelativePattern(uri, '**/*.php')); + return files.map(file => path.relative(uri.fsPath, file.fsPath)); + } + public static async writeFile(uri: Uri, content: string): Promise { await workspace.fs.writeFile(uri, Buffer.from(content)); }