Skip to main content

Linting neon files in CI

· 4 min read
Stephan Hochdörfer

I have to admit, I am not the best at editing neon configuration files. I regularly mess that up and then things break. How to fix that? Well, why not lint those files in the CI pipeline?

That's exactly what I did for our bitexpert/phpstan-magento extension for PHPStan.

I've built myself a little helper script in PHP that will check if the .neon files in the project can be parsed, if referenced classes exist, and if the schema defined for parameters matches the actual parameters.

First the basic structure of the script. We try to find all .neon files in the project recursively but exclude the vendor directory. Since all code in the vendor directory is beyond our control, we don't care about that code:

$path = realpath(__DIR__ . '/../');
$it = new RecursiveDirectoryIterator($path);
$it = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::LEAVES_ONLY);
$it = new RegexIterator($it, '~\.neon$~');

$success = true;
foreach ($it as $file) {
/** @var SplFileInfo $file */
if (strpos($file->getRealPath(), '/vendor/') !== false) {
continue;
}

// check if the .neon file can be decoded...

// check if the parameters match the structure defined in parametersSchema
}

To check if the .neon files found can be decoded, we can use Nette\Neon\Neon::decodeFile():

try {
$neon = Nette\Neon\Neon::decodeFile($file->getRealPath());
} catch (\Nette\Neon\Exception $e) {
$relPath = str_replace($path . DIRECTORY_SEPARATOR, '', $file->getRealPath());
echo sprintf('Failed parsing file "%s"', $relPath)."\n";
}

Since we reference classes in the .neon file, it would be nice to check if the classes exist, just to be sure no typo was made. So let's extend the code above a little bit:

try {
$neon = Nette\Neon\Neon::decodeFile($file->getRealPath());
array_walk_recursive($neon, function($value, $key) {
if (($key === 'class') && !class_exists($value)) {
throw new \RuntimeException(sprintf('Class "%s" does not exist', $value));
}
});
} catch (\Nette\Neon\Exception $e) {
$relPath = str_replace($path . DIRECTORY_SEPARATOR, '', $file->getRealPath());
echo sprintf('Failed parsing file "%s"', $relPath)."\n";
} catch (\RuntimeException $e) {
echo $e->getMessage()."\n";
}

The Nette\Neon\Neon::decodeFile() returns an array that can potentially contain nested arrays. The easiest way of recursively walking through that array is with the array_walk_recursive() function. In the callback function, we check if the array key is class and if so, we check if the value is actually a class string that PHP (technically the Composer autoloader) can load. If not, we throw an exception which will make the script fail and break the build.

How to check if the parameters section matches the parametersSchema definition? That's slightly more complicated because the nette/schema Composer package does not seem to come with functionality to automatically convert the schema definition from the .neon file into a \Nette\Schema\Elements\Structure object which is needed by \Nette\Schema\Processor::process() to validate the parameters against the schema.

I came up with this little helper function to assist me in the conversion. It's most likely not perfect but good enough for our use case:

function convertToNetteSchemaElement($entity)
{
$schema = [];

if ($entity instanceof \Nette\Neon\Entity) {
if (count($entity->attributes) === 0) {
return new \Nette\Schema\Elements\Type((string)$entity->value);
}

foreach($entity->attributes as $key => $value) {
if (is_array($value)) {
return convertToNetteSchemaElement($value);
} else {
$schema[$key] = new \Nette\Schema\Elements\Type($value);
}
}
} else if (is_array($entity)) {
foreach ($entity as $key => $value) {
$schema[$key] = convertToNetteSchemaElement($value);
}
}

return new \Nette\Schema\Elements\Structure($schema);
}

Then in the main logic loop, the following code is added to parse the schema and invoke the schema validation call:

try {
$neon = Nette\Neon\Neon::decodeFile($file->getRealPath());
if(isset($neon['parameters']) && isset($neon['parametersSchema'])) {
$schema = [];
foreach($neon['parametersSchema'] as $key => $item) {
$schema[$key] = convertToNetteSchemaElement($item);
}
$schema = new \Nette\Schema\Elements\Structure($schema);

$processor = new \Nette\Schema\Processor();
$processor->process($schema, $neon['parameters']);
}
} catch (\Nette\Schema\ValidationException $e) {
$success = false;
echo sprintf("Schema validation failed: %s", $e->getMessage())."\n";
} catch (\Nette\Neon\Exception $e) {
$success = false;
$relPath = str_replace($path . DIRECTORY_SEPARATOR, '', $file->getRealPath());
echo sprintf('Failed parsing file "%s"', $relPath)."\n";
}