diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eb346a..9d91846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how ## [Unreleased] - Added: Event name autocomplete +- Added: Hovering CRON job schedules will show a human readable version +- Added: Cron job indexer and instance class decorations +- Changed: Implemented batching for the indexer to reduce load ## [1.5.0] - 2025-04-06 - Added: Class namespace autocomplete in XML files diff --git a/package-lock.json b/package-lock.json index 26c8ba2..2076495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@xml-tools/parser": "^1.0.11", "@xml-tools/simple-schema": "^3.0.5", "@xml-tools/validation": "^1.0.16", + "cronstrue": "^2.59.0", "fast-xml-parser": "^4.5.1", "formik": "^2.4.6", "glob": "^11.0.1", @@ -2573,6 +2574,15 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "node_modules/cronstrue": { + "version": "2.59.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.59.0.tgz", + "integrity": "sha512-YKGmAy84hKH+hHIIER07VCAHf9u0Ldelx1uU6EBxsRPDXIA1m5fsKmJfyC3xBhw6cVC/1i83VdbL4PvepTrt8A==", + "license": "MIT", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index bf72cd9..82cc62f 100644 --- a/package.json +++ b/package.json @@ -427,6 +427,7 @@ "@xml-tools/parser": "^1.0.11", "@xml-tools/simple-schema": "^3.0.5", "@xml-tools/validation": "^1.0.16", + "cronstrue": "^2.59.0", "fast-xml-parser": "^4.5.1", "formik": "^2.4.6", "glob": "^11.0.1", diff --git a/resources/icons/cron.svg b/resources/icons/cron.svg new file mode 100644 index 0000000..52c0cc3 --- /dev/null +++ b/resources/icons/cron.svg @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/src/decorator/CronClassDecorationProvider.ts b/src/decorator/CronClassDecorationProvider.ts new file mode 100644 index 0000000..fcd2c5f --- /dev/null +++ b/src/decorator/CronClassDecorationProvider.ts @@ -0,0 +1,91 @@ +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 PhpDocumentParser from 'common/php/PhpDocumentParser'; +import { ClasslikeInfo } from 'common/php/ClasslikeInfo'; +import MarkdownMessageBuilder from 'common/MarkdownMessageBuilder'; +import IndexManager from 'indexer/IndexManager'; +import CronIndexer from 'indexer/cron/CronIndexer'; +import { Job } from 'indexer/cron/types'; +import cronstrue from 'cronstrue'; + +export default class CronClassDecorationProvider extends TextDocumentDecorationProvider { + public getType(): TextEditorDecorationType { + return window.createTextEditorDecorationType({ + gutterIconPath: path.join(__dirname, 'resources', 'icons', 'cron.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 classlikeInfo = new ClasslikeInfo(phpFile); + + const cronIndexData = IndexManager.getIndexData(CronIndexer.KEY); + + if (!cronIndexData) { + return decorations; + } + + const jobs = cronIndexData.findJobsByInstance(classlikeInfo.getNamespace()); + + if (jobs.length === 0) { + return decorations; + } + + decorations.push(...this.getCronInstanceDecorations(jobs, classlikeInfo)); + + return decorations; + } + + private getCronInstanceDecorations( + jobs: Job[], + classlikeInfo: ClasslikeInfo + ): DecorationOptions[] { + const decorations: DecorationOptions[] = []; + + const nameRange = classlikeInfo.getNameRange(); + + if (!nameRange) { + return decorations; + } + + const hoverMessage = MarkdownMessageBuilder.create('Cron Jobs'); + + for (const job of jobs) { + hoverMessage.appendMarkdown(`- [${job.name}](${Uri.file(job.path)})\n`); + hoverMessage.appendMarkdown(` - Method: \`${job.method}\`\n`); + + if (job.schedule) { + hoverMessage.appendMarkdown( + ` - \`${job.schedule}\` (${cronstrue.toString(job.schedule)})\n` + ); + } + + if (job.config_path) { + hoverMessage.appendMarkdown(` - Config: \`${job.config_path}\`\n`); + } + } + + decorations.push({ + range: nameRange, + hoverMessage: hoverMessage.build(), + }); + + return decorations; + } +} diff --git a/src/hover/XmlHoverProviderProcessor.ts b/src/hover/XmlHoverProviderProcessor.ts index 38279c7..fafd1d8 100644 --- a/src/hover/XmlHoverProviderProcessor.ts +++ b/src/hover/XmlHoverProviderProcessor.ts @@ -2,10 +2,11 @@ import { CancellationToken, Hover, Position, TextDocument } from 'vscode'; import { XmlSuggestionProviderProcessor } from 'common/xml/XmlSuggestionProviderProcessor'; import { AclHoverProvider } from 'hover/xml/AclHoverProvider'; import { ModuleHoverProvider } from 'hover/xml/ModuleHoverProvider'; +import { CronHoverProvider } from 'hover/xml/CronHoverProvider'; export class XmlHoverProviderProcessor extends XmlSuggestionProviderProcessor { public constructor() { - super([new AclHoverProvider(), new ModuleHoverProvider()]); + super([new AclHoverProvider(), new ModuleHoverProvider(), new CronHoverProvider()]); } public async provideHover( diff --git a/src/hover/xml/CronHoverProvider.ts b/src/hover/xml/CronHoverProvider.ts new file mode 100644 index 0000000..23302bf --- /dev/null +++ b/src/hover/xml/CronHoverProvider.ts @@ -0,0 +1,31 @@ +import { Hover, MarkdownString, Range } from 'vscode'; +import { CombinedCondition, XmlSuggestionProvider } from 'common/xml/XmlSuggestionProvider'; +import { ElementNameMatches } from 'common/xml/suggestion/condition/ElementNameMatches'; +import cronstrue from 'cronstrue'; + +export class CronHoverProvider extends XmlSuggestionProvider { + public getElementContentMatches(): CombinedCondition[] { + return [[new ElementNameMatches('schedule')]]; + } + + public getConfigKey(): string | undefined { + return 'provideXmlHovers'; + } + + public getFilePatterns(): string[] { + return ['**/etc/crontab.xml']; + } + + public getSuggestionItems(value: string, range: Range): Hover[] { + const readable = cronstrue.toString(value); + + if (!readable) { + return []; + } + + const markdown = new MarkdownString(); + markdown.appendMarkdown(`**Cron**: ${readable}`); + + return [new Hover(markdown, range)]; + } +} diff --git a/src/indexer/IndexManager.ts b/src/indexer/IndexManager.ts index 2767a26..5208ed2 100644 --- a/src/indexer/IndexManager.ts +++ b/src/indexer/IndexManager.ts @@ -18,6 +18,8 @@ import AclIndexer from './acl/AclIndexer'; import { AclIndexData } from './acl/AclIndexData'; import TemplateIndexer from './template/TemplateIndexer'; import { TemplateIndexData } from './template/TemplateIndexData'; +import CronIndexer from './cron/CronIndexer'; +import { CronIndexData } from './cron/CronIndexData'; type IndexerInstance = | DiIndexer @@ -25,7 +27,8 @@ type IndexerInstance = | AutoloadNamespaceIndexer | EventsIndexer | AclIndexer - | TemplateIndexer; + | TemplateIndexer + | CronIndexer; type IndexerDataMap = { [DiIndexer.KEY]: DiIndexData; @@ -34,6 +37,7 @@ type IndexerDataMap = { [EventsIndexer.KEY]: EventsIndexData; [AclIndexer.KEY]: AclIndexData; [TemplateIndexer.KEY]: TemplateIndexData; + [CronIndexer.KEY]: CronIndexData; }; class IndexManager { @@ -50,6 +54,7 @@ class IndexManager { new EventsIndexer(), new AclIndexer(), new TemplateIndexer(), + new CronIndexer(), ]; this.indexStorage = new IndexStorage(); } @@ -187,6 +192,9 @@ class IndexManager { case TemplateIndexer.KEY: return new TemplateIndexData(data) as IndexerDataMap[T]; + case CronIndexer.KEY: + return new CronIndexData(data) as IndexerDataMap[T]; + default: return undefined; } diff --git a/src/indexer/cron/CronIndexData.ts b/src/indexer/cron/CronIndexData.ts new file mode 100644 index 0000000..9d7809e --- /dev/null +++ b/src/indexer/cron/CronIndexData.ts @@ -0,0 +1,25 @@ +import { Memoize } from 'typescript-memoize'; +import { Job } from './types'; +import { AbstractIndexData } from 'indexer/AbstractIndexData'; +import CronIndexer from './CronIndexer'; + +export class CronIndexData extends AbstractIndexData { + @Memoize({ + tags: [CronIndexer.KEY], + }) + public getJobs(): Job[] { + return this.getValues().flatMap(data => data); + } + + public findJobByName(group: string, name: string): Job | undefined { + return this.getJobs().find(job => job.group === group && job.name === name); + } + + public findJobsByGroup(group: string): Job[] { + return this.getJobs().filter(job => job.group === group); + } + + public findJobsByInstance(instance: string): Job[] { + return this.getJobs().filter(job => job.instance === instance); + } +} diff --git a/src/indexer/cron/CronIndexer.ts b/src/indexer/cron/CronIndexer.ts new file mode 100644 index 0000000..887110f --- /dev/null +++ b/src/indexer/cron/CronIndexer.ts @@ -0,0 +1,70 @@ +import { RelativePattern, Uri } from 'vscode'; +import { XMLParser } from 'fast-xml-parser'; +import { get } from 'lodash-es'; +import FileSystem from 'util/FileSystem'; +import { Job } from './types'; +import { Indexer } from 'indexer/Indexer'; +import { IndexerKey } from 'types/indexer'; + +export default class CronIndexer extends Indexer { + public static readonly KEY = 'cron'; + + protected xmlParser: XMLParser; + + public constructor() { + super(); + + this.xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (_name, jpath) => { + return ['config.group', 'config.group.job'].includes(jpath); + }, + }); + } + + public getVersion(): number { + return 2; + } + + public getId(): IndexerKey { + return CronIndexer.KEY; + } + + public getName(): string { + return 'crontab.xml'; + } + + public getPattern(uri: Uri): RelativePattern { + return new RelativePattern(uri, '**/etc/crontab.xml'); + } + + 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: Job[] = []; + + // Index groups + const groups = get(config, 'group', []); + + for (const group of groups) { + const jobs = get(group, 'job', []); + + for (const job of jobs) { + data.push({ + name: job['@_name'], + instance: job['@_instance'], + method: job['@_method'], + schedule: job['schedule'], + config_path: job['config_path'], + path: uri.fsPath, + group: group['@_id'], + }); + } + } + + return data; + } +} diff --git a/src/indexer/cron/types.ts b/src/indexer/cron/types.ts new file mode 100644 index 0000000..e2a9948 --- /dev/null +++ b/src/indexer/cron/types.ts @@ -0,0 +1,9 @@ +export interface Job { + name: string; + instance: string; + method: string; + schedule?: string; + config_path?: string; + path: string; + group: string; +} diff --git a/src/observer/ActiveTextEditorChangeObserver.ts b/src/observer/ActiveTextEditorChangeObserver.ts index 80ba9fc..928ef4c 100644 --- a/src/observer/ActiveTextEditorChangeObserver.ts +++ b/src/observer/ActiveTextEditorChangeObserver.ts @@ -3,6 +3,7 @@ import Observer from './Observer'; import PluginClassDecorationProvider from 'decorator/PluginClassDecorationProvider'; import Context from 'common/Context'; import ObserverInstanceDecorationProvider from 'decorator/ObserverInstanceDecorationProvider'; +import CronClassDecorationProvider from 'decorator/CronClassDecorationProvider'; export default class ActiveTextEditorChangeObserver extends Observer { public async execute(textEditor: TextEditor | undefined): Promise { @@ -11,14 +12,17 @@ export default class ActiveTextEditorChangeObserver extends Observer { if (textEditor && textEditor.document.languageId === 'php') { const pluginProvider = new PluginClassDecorationProvider(textEditor.document); const observerProvider = new ObserverInstanceDecorationProvider(textEditor.document); + const cronProvider = new CronClassDecorationProvider(textEditor.document); - const [pluginDecorations, observerDecorations] = await Promise.all([ + const [pluginDecorations, observerDecorations, cronDecorations] = await Promise.all([ pluginProvider.getDecorations(), observerProvider.getDecorations(), + cronProvider.getDecorations(), ]); textEditor.setDecorations(pluginProvider.getType(), pluginDecorations); textEditor.setDecorations(observerProvider.getType(), observerDecorations); + textEditor.setDecorations(cronProvider.getType(), cronDecorations); } } }