Skip to content

Add Missing Strings Validation for Moodle Plugin CI #356

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions bin/moodle-plugin-ci
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use MoodlePluginCI\Command\CoverallsUploadCommand;
use MoodlePluginCI\Command\GruntCommand;
use MoodlePluginCI\Command\InstallCommand;
use MoodlePluginCI\Command\MessDetectorCommand;
use MoodlePluginCI\Command\MissingStringsCommand;
use MoodlePluginCI\Command\MustacheCommand;
use MoodlePluginCI\Command\ParallelCommand;
use MoodlePluginCI\Command\PHPDocCommand;
Expand Down Expand Up @@ -89,6 +90,7 @@ $application->add(new CoverallsUploadCommand());
$application->add(new GruntCommand());
$application->add(new InstallCommand(ENV_FILE));
$application->add(new MessDetectorCommand());
$application->add(new MissingStringsCommand());
$application->add(new MustacheCommand());
$application->add(new ParallelCommand());
$application->add(new PHPDocCommand());
Expand Down
206 changes: 206 additions & 0 deletions src/Command/MissingStringsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<?php

/*
* This file is part of the Moodle Plugin CI package.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* Copyright (c) 2025 Volodymyr Dovhan (https://github.com/volodymyrdovhan)
* License http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

namespace MoodlePluginCI\Command;

use MoodlePluginCI\MissingStrings\StringValidator;
use MoodlePluginCI\MissingStrings\ValidationConfig;
use MoodlePluginCI\MissingStrings\ValidationResult;
use MoodlePluginCI\PluginValidate\Plugin;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
* Find missing language strings in a plugin.
*/
class MissingStringsCommand extends AbstractMoodleCommand
{
/**
* Configure the command.
*
* @return void
*/
protected function configure(): void
{
parent::configure();

$this->setName('missingstrings')
->setAliases(['missing-strings'])
->setDescription('Find missing language strings in a plugin')
->addOption(
'lang',
'l',
InputOption::VALUE_REQUIRED,
'Language to validate against',
'en'
)
->addOption(
'strict',
null,
InputOption::VALUE_NONE,
'Strict mode - treat warnings as errors'
)
->addOption(
'unused',
'u',
InputOption::VALUE_NONE,
'Report unused strings as warnings'
)
->addOption(
'exclude-patterns',
null,
InputOption::VALUE_REQUIRED,
'Comma-separated list of string patterns to exclude from validation',
''
)
->addOption(
'debug',
'd',
InputOption::VALUE_NONE,
'Enable debug mode for detailed error information'
);
}

/**
* Execute the command.
*
* @param InputInterface $input the input interface
* @param OutputInterface $output the output interface
*
* @return int the exit code
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->outputHeading($output, 'Checking for missing language strings in %s');

// Create configuration from command line options
$config = ValidationConfig::fromOptions([
'lang' => $input->getOption('lang'),
'strict' => $input->getOption('strict'),
'unused' => $input->getOption('unused'),
'exclude-patterns' => $input->getOption('exclude-patterns'),
'debug' => $input->getOption('debug'),
]);

// Convert MoodlePlugin to Plugin object
list($type, $name) = $this->moodle->normalizeComponent($this->plugin->getComponent());
$plugin = new Plugin($this->plugin->getComponent(), $type, $name, $this->plugin->directory);

$validator = new StringValidator(
$plugin,
$this->moodle,
$config
);

$result = $validator->validate();

// Show only errors and warnings
foreach ($result->getMessages() as $message) {
$output->writeln($message);
}

// Show summary statistics
$this->outputSummary($output, $result);

// Show debug information if debug mode is enabled
if ($config->isDebugEnabled()) {
$this->outputDebugInformation($output, $result);
}

return $result->isValid() ? 0 : 1;
}

/**
* Output summary statistics.
*
* @param OutputInterface $output the output interface
* @param ValidationResult $result the validation result
*/
private function outputSummary(OutputInterface $output, ValidationResult $result): void
{
$output->writeln('');
$output->writeln('<comment>Summary:</comment>');

$summary = $result->getSummary();

if ($summary['errors'] > 0) {
$output->writeln(sprintf('- <fg=red>Errors: %d</>', $summary['errors']));
}

if ($summary['warnings'] > 0) {
$output->writeln(sprintf('- <comment>Warnings: %d</comment>', $summary['warnings']));
}

if ($summary['total_issues'] === 0) {
$output->writeln('- <info>No issues found</info>');
} else {
$output->writeln(sprintf('- <comment>Total issues: %d</comment>', $summary['total_issues']));
}

$output->writeln('');

if ($summary['is_valid']) {
$output->writeln('<info>✓ All language strings are valid</info>');
} else {
$output->writeln('<error>✗ Language string validation failed</error>');
}
}

/**
* Output debug performance information.
*
* @param OutputInterface $output the output interface
* @param ValidationResult $result the validation result
*/
private function outputDebugInformation(OutputInterface $output, ValidationResult $result): void
{
$debugData = $result->getDebugData();

$output->writeln('');
$output->writeln('<comment>Debug Performance Information:</comment>');

// Overall timing
if ($debugData['processing_time'] > 0) {
$output->writeln(sprintf('- <info>Total processing time: %.3f seconds</info>', $debugData['processing_time']));
}

// Plugin counts
$totalPlugins = $debugData['plugin_count'] + $debugData['subplugin_count'];
$output->writeln(sprintf('- <info>Plugins processed: %d</info>', $totalPlugins));
if ($debugData['subplugin_count'] > 0) {
$output->writeln(sprintf(' - Main: %d, Subplugins: %d', $debugData['plugin_count'], $debugData['subplugin_count']));
}

// Total files count
if (!empty($debugData['file_counts'])) {
$totalFiles = $debugData['file_counts']['total_files'] ?? 0;
if ($totalFiles > 0) {
$output->writeln(sprintf('- <info>Files processed: %d</info>', $totalFiles));
}
}

// String processing metrics
if (!empty($debugData['string_counts'])) {
$output->writeln('- <info>String processing metrics:</info>');
foreach ($debugData['string_counts'] as $type => $count) {
if ($count > 0) {
/** @var string $type */
$displayName = str_replace('_', ' ', $type);
$output->writeln(sprintf(' - %s: %d', ucfirst($displayName), $count));
}
}
}

$output->writeln('');
}
}
168 changes: 168 additions & 0 deletions src/MissingStrings/Cache/FileContentCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Moodle Plugin CI package.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* Copyright (c) 2025 Volodymyr Dovhan (https://github.com/volodymyrdovhan)
* License http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

namespace MoodlePluginCI\MissingStrings\Cache;

/**
* Simple file content cache to avoid repeated file reads during validation.
*
* This cache is designed to be lightweight and session-scoped, improving
* performance when multiple checkers need to read the same files.
*/
class FileContentCache
{
/**
* Cache storage for file contents.
*/
private static array $contentCache = [];

/**
* Cache storage for file modification times.
*/
private static array $mtimeCache = [];

/**
* Maximum number of files to cache (memory limit protection).
*/
private const MAX_CACHED_FILES = 100;

/**
* Get file content with caching.
*
* @param string $filePath absolute path to the file
*
* @return string|false file content or false on failure
*/
/**
* @return string|false
*/
public static function getContent(string $filePath)
{
// Normalize path for consistent caching
$normalizedPath = realpath($filePath);
if (false === $normalizedPath) {
return false;
}

// Check if file exists and is readable
if (!is_file($normalizedPath) || !is_readable($normalizedPath)) {
return false;
}

$currentMtime = filemtime($normalizedPath);
if (false === $currentMtime) {
return false;
}

// Check if we have a cached version that's still valid
if (
isset(self::$contentCache[$normalizedPath], self::$mtimeCache[$normalizedPath])
&& self::$mtimeCache[$normalizedPath] === $currentMtime
) {
return self::$contentCache[$normalizedPath];
}

// Read file content
$content = file_get_contents($normalizedPath);
if (false === $content) {
return false;
}

// Store in cache (with size limit)
if (count(self::$contentCache) >= self::MAX_CACHED_FILES) {
// Remove oldest cache entry (simple FIFO)
$oldestKey = array_key_first(self::$contentCache);
if (null !== $oldestKey) {
unset(self::$contentCache[$oldestKey], self::$mtimeCache[$oldestKey]);
}
}

self::$contentCache[$normalizedPath] = $content;
self::$mtimeCache[$normalizedPath] = $currentMtime;

return $content;
}

/**
* Check if a file exists and is readable (with caching).
*
* @param string $filePath absolute path to the file
*
* @return bool true if file exists and is readable
*/
public static function fileExists(string $filePath): bool
{
$normalizedPath = realpath($filePath);
if (false === $normalizedPath) {
return file_exists($filePath) && is_readable($filePath);
}

// If we have the file in cache, it exists and is readable
if (isset(self::$contentCache[$normalizedPath])) {
return true;
}

return is_file($normalizedPath) && is_readable($normalizedPath);
}

/**
* Get file lines as array with caching.
*
* @param string $filePath absolute path to the file
* @param int $flags flags for file() function
*
* @return array|false array of lines or false on failure
*/
/**
* @return array|false
*/
public static function getLines(string $filePath, int $flags = FILE_IGNORE_NEW_LINES)
{
$content = self::getContent($filePath);
if (false === $content) {
return false;
}

if ($flags & FILE_IGNORE_NEW_LINES) {
return explode("\n", rtrim($content, "\n"));
}

return explode("\n", $content);
}

/**
* Clear the entire cache.
*
* Useful for testing or when memory usage needs to be reduced.
*/
public static function clearCache(): void
{
self::$contentCache = [];
self::$mtimeCache = [];
}

/**
* Get cache statistics for debugging.
*
* @return array cache statistics
*/
public static function getStats(): array
{
return [
'cached_files' => count(self::$contentCache),
'max_files' => self::MAX_CACHED_FILES,
'memory_usage' => array_sum(array_map('strlen', self::$contentCache)),
];
}
}
Loading
Loading