Skip to content

Feature/crontab features #89

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions resources/icons/cron.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
91 changes: 91 additions & 0 deletions src/decorator/CronClassDecorationProvider.ts
Original file line number Diff line number Diff line change
@@ -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<DecorationOptions[]> {
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;
}
}
3 changes: 2 additions & 1 deletion src/hover/XmlHoverProviderProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hover> {
public constructor() {
super([new AclHoverProvider(), new ModuleHoverProvider()]);
super([new AclHoverProvider(), new ModuleHoverProvider(), new CronHoverProvider()]);
}

public async provideHover(
Expand Down
31 changes: 31 additions & 0 deletions src/hover/xml/CronHoverProvider.ts
Original file line number Diff line number Diff line change
@@ -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<Hover> {
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)];
}
}
10 changes: 9 additions & 1 deletion src/indexer/IndexManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ 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
| ModuleIndexer
| AutoloadNamespaceIndexer
| EventsIndexer
| AclIndexer
| TemplateIndexer;
| TemplateIndexer
| CronIndexer;

type IndexerDataMap = {
[DiIndexer.KEY]: DiIndexData;
Expand All @@ -34,6 +37,7 @@ type IndexerDataMap = {
[EventsIndexer.KEY]: EventsIndexData;
[AclIndexer.KEY]: AclIndexData;
[TemplateIndexer.KEY]: TemplateIndexData;
[CronIndexer.KEY]: CronIndexData;
};

class IndexManager {
Expand All @@ -50,6 +54,7 @@ class IndexManager {
new EventsIndexer(),
new AclIndexer(),
new TemplateIndexer(),
new CronIndexer(),
];
this.indexStorage = new IndexStorage();
}
Expand Down Expand Up @@ -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;
}
Expand Down
25 changes: 25 additions & 0 deletions src/indexer/cron/CronIndexData.ts
Original file line number Diff line number Diff line change
@@ -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<Job[]> {
@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);
}
}
70 changes: 70 additions & 0 deletions src/indexer/cron/CronIndexer.ts
Original file line number Diff line number Diff line change
@@ -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<Job[]> {
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<Job[]> {
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;
}
}
9 changes: 9 additions & 0 deletions src/indexer/cron/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface Job {
name: string;
instance: string;
method: string;
schedule?: string;
config_path?: string;
path: string;
group: string;
}
6 changes: 5 additions & 1 deletion src/observer/ActiveTextEditorChangeObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -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);
}
}
}