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);
}
}
}