#!/usr/bin/env php
<?php
/*
 * Copyright 2017 The LazyJsonMapper Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/*
 * The Lazy Doctor.
 *
 * This tool automatically builds up-to-date class documentation for all classes
 * that derive from LazyJsonMapper, and documents their virtual properties and
 * functions by adding "@property" and "@method" declarations to their class
 * PHPdoc blocks. That documentation is necessary for things like code analysis
 * tools and IDE autocomplete for all of your virtual properties/functions!
 *
 * LazyDoctor also performs class diagnostics: It compiles the class property
 * maps of _all_ of your LazyJsonMapper-based classes, which means that you can
 * be 100% sure that all of your maps are valid if this tool runs successfully.
 *
 * To see all available options, type "./vendor/bin/lazydoctor --help".
 *
 * To begin, simply point the "--composer" option at the "composer.json" file
 * for your project. Just be aware that your project MUST be based on PSR-4
 * autoloading, with ONE OR MORE defined autoload-namespaces in "composer.json".
 *
 * You can read more about PSR-4 autoloading at the official Composer site:
 *
 * https://getcomposer.org/doc/04-schema.md#autoload
 *
 * This tool will ONLY parse classes that properly follow PSR-4 autoloading!
 *
 * In addition to the composer-file, you'll also have to specify whether you
 * want to document virtual "--properties", virtual "--functions", or BOTH.
 * Note that we'll ONLY document them when the individual classes support that
 * kind of access (when it hasn't disabled its class-option for that feature).
 *
 * If you don't provide any documentation flags, we'll instead REMOVE all of
 * the current class "virtual @property/@method" documentation. That can be
 * useful for restoring your files if you don't want to document them anymore.
 *
 * You should also be aware of "--document-overridden", which will document
 * virtual properties or functions even if they're already manually defined
 * (overridden) by the class or its parents. That can be useful if you want to
 * ensure that you ALWAYS document the virtual properties/functions in all of
 * your class files even when it has been overridden somewhere. In that case,
 * you should ensure that your overridden properties/functions have the SAME or
 * a COMPATIBLE signature & return value, so their auto-docs are still correct.
 *
 * Tip: You can use the "--validate-only" param to check docs without writing to
 * disk. That can be useful when making a non-destructive Git "pre-commit hook"
 * to validate your repo and ensure that all files have updated documentation!
 *
 * This script always uses exit codes to indicate success. A non-zero exit code
 * indicates that some files needed new class docs (in "--validate-only" mode),
 * or that there was a general problem while processing.
 *
 * Lastly, you may appreciate the ability to silence all unimportant status
 * messages! To do so, simply use ">/dev/null" to send STDOUT to the void...
 * That way you'll ONLY see critical status messages during the processing.
 */

set_time_limit(0);
date_default_timezone_set('UTC');

// Verify minimum PHP version.
if (!defined('PHP_VERSION_ID') || PHP_VERSION_ID < 50600) {
    fwrite(STDERR, 'LazyDoctor requires PHP 5.6 or higher.'.PHP_EOL);
    exit(1);
}

// Register a simple GetOptionKit autoloader. This is fine because
// GetOptionKit has no 3rd party library dependencies.
spl_autoload_register(function ($class) {
    // Check if this is a "GetOptionKit" load-request.
    static $prefix = 'GetOptionKit\\';
    static $len = 13; // strlen($prefix)
    if (strncmp($prefix, $class, $len) !== 0) {
        return;
    }

    // Find the "GetOptionKit" source folder.
    static $dirs = [
        __DIR__.'/../../../corneltek/getoptionkit/src',
        __DIR__.'/../vendor/corneltek/getoptionkit/src',
    ];
    $baseDir = null;
    foreach ($dirs as $dir) {
        if (is_dir($dir) && ($dir = realpath($dir)) !== false) {
            $baseDir = $dir;
            break;
        }
    }
    if ($baseDir === null) {
        return;
    }

    // Get the relative class name.
    $relativeClass = substr($class, $len);

    // Generate PSR-4 file path to the class.
    $file = sprintf('%s/%s.php', $baseDir, str_replace('\\', '/', $relativeClass));
    if (is_file($file)) {
        require $file;
    }
});

// Parse command line options...
use GetOptionKit\OptionCollection;
use GetOptionKit\OptionParser;
use GetOptionKit\OptionPrinter\ConsoleOptionPrinter;

$specs = new OptionCollection();
$specs->add('c|composer:=file', 'Path to your project\'s composer.json file.');
$specs->add('p|properties?=boolean', 'Document virtual properties (if enabled for the classes).');
$specs->add('f|functions?=boolean', 'Document virtual functions (if enabled for the classes).');
$specs->add('o|document-overridden?=boolean', 'Always document virtual functions/properties even when they have been manually overridden by the class (or its parents).');
$specs->add('w|windows?=boolean', 'Generate Windows-style ("\r\n") documentation line endings instead of the default Unix-style ("\n").');
$specs->add('validate-only?=boolean', 'Validate current docs for all classes but don\'t write anything to disk.');
$specs->add('h|help?=boolean', 'Show all available options.');

try {
    $parser = new OptionParser($specs);
    $result = $parser->parse($argv);
    $options = [
        'composer'            => isset($result->keys['composer']) ? $result->keys['composer']->value : null,
        'properties'          => isset($result->keys['properties']) && $result->keys['properties']->value !== false,
        'functions'           => isset($result->keys['functions']) && $result->keys['functions']->value !== false,
        'document-overridden' => isset($result->keys['document-overridden']) && $result->keys['document-overridden']->value !== false,
        'windows'             => isset($result->keys['windows']) && $result->keys['windows']->value !== false,
        'validate-only'       => isset($result->keys['validate-only']) && $result->keys['validate-only']->value !== false,
        'help'                => isset($result->keys['help']) && $result->keys['help']->value !== false,
    ];
} catch (Exception $e) {
    // Warns in case of invalid option values.
    fwrite(STDERR, $e->getMessage().PHP_EOL);
    exit(1);
}

// Verify options...
echo '[ LazyDoctor ]'.PHP_EOL.PHP_EOL;
if ($options['composer'] === null || $options['help']) {
    if ($options['composer'] === null) {
        fwrite(STDERR, 'You must provide the --composer option.'.PHP_EOL.PHP_EOL);
    }
    $printer = new ConsoleOptionPrinter();
    echo 'Available options:'.PHP_EOL.PHP_EOL;
    echo $printer->render($specs);
    exit($options['composer'] === null && !$options['help'] ? 1 : 0);
}

if ($options['composer']->getBasename() !== 'composer.json') {
    fwrite(STDERR, 'You must point to your project\'s composer.json file.'.PHP_EOL.'You used: "'.$options['composer']->getRealPath().'".'.PHP_EOL);
    exit(1);
}

// Decode the composer.json file...
$json = @json_decode(file_get_contents($options['composer']->getRealPath()), true);
if ($json === null) {
    fwrite(STDERR, 'Unable to decode composer.json.'.PHP_EOL);
    exit(1);
}

// Determine the project folder's real root path...
$projectRoot = $options['composer']->getPathInfo()->getRealPath();

// Determine their namespace PSR-4 paths via their project's composer.json...
$namespaces = [];
foreach (['autoload', 'autoload-dev'] as $type) {
    if (!isset($json[$type]['psr-4']) || !is_array($json[$type]['psr-4'])) {
        continue;
    }

    foreach ($json[$type]['psr-4'] as $namespace => $dir) {
        // We don't support composer's empty "fallback" namespaces.
        if ($namespace === '') {
            fwrite(STDERR, 'Encountered illegal unnamed PSR-4 autoload namespace in composer.json.'.PHP_EOL);
            exit(1);
        }

        // Ensure that the namespace ends in backslash.
        if (substr_compare($namespace, '\\', strlen($namespace) - 1, 1) !== 0) {
            fwrite(STDERR, 'Encountered illegal namespace "'.$namespace.'" (does not end in backslash) in composer.json.'.PHP_EOL);
            exit(1);
        }

        // Ensure that the value is a string.
        // NOTE: We allow empty strings, which corresponds to root folder.
        if (!is_string($dir)) {
            fwrite(STDERR, 'Encountered illegal non-string value for namespace "'.$namespace.'".'.PHP_EOL);
            exit(1);
        }

        // Now resolve the path name...
        $path = sprintf('%s/%s', $projectRoot, $dir);
        $realpath = realpath($path);
        if ($realpath === false) {
            fwrite(STDERR, 'Unable to resolve real path for "'.$path.'".'.PHP_EOL);
            exit(1);
        }

        // We don't allow the same directory to be defined multiple times.
        if (isset($namespaces[$realpath])) {
            fwrite(STDERR, 'Encountered duplicate namespace directory "'.$realpath.'" in composer.json.'.PHP_EOL);
            exit(1);
        }

        // And we're done! The namespace and its path have been resolved.
        $namespaces[$realpath] = $namespace;
    }
}

// Verify that we found some namespaces...
if (empty($namespaces)) {
    fwrite(STDERR, 'There are no PSR-4 autoload namespaces in your composer.json.'.PHP_EOL);
    exit(1);
}

// Now load the project's autoload.php file.
// NOTE: This is necessary so that we can autoload their classes...
$autoload = sprintf('%s/vendor/autoload.php', $projectRoot);
$realautoload = realpath($autoload);
if ($realautoload === false) {
    fwrite(STDERR, 'Unable to find the project\'s Composer autoloader ("'.$autoload.'").'.PHP_EOL);
    exit(1);
}
require $realautoload;

// Verify that their project's autoloader contains LazyJsonMapper...
if (!class_exists('\LazyJsonMapper\LazyJsonMapper', true)) { // TRUE = Autoload.
    fwrite(STDERR, 'Target project doesn\'t contain the LazyJsonMapper library.'.PHP_EOL);
    exit(1);
}

// Alright, display the current options...
echo 'Project: "'.$projectRoot.'".'.PHP_EOL
    .'- Documentation Line Endings: '.($options['windows'] ? 'Windows ("\r\n")' : 'Unix ("\n")').'.'.PHP_EOL
    .'- ['.($options['properties'] ? 'X' : ' ').'] Document Virtual Properties ("@property").'.PHP_EOL
    .'- ['.($options['functions'] ? 'X' : ' ').'] Document Virtual Functions ("@method").'.PHP_EOL
    .'- ['.($options['document-overridden'] ? 'X' : ' ').'] Document Overridden Properties/Functions.'.PHP_EOL;
if ($options['validate-only']) {
    echo '- This is a validation run. Nothing will be written to disk.'.PHP_EOL;
}

// We can now use our custom classes, since the autoloader has been imported...
use LazyJsonMapper\Exception\LazyJsonMapperException;
use LazyJsonMapper\Export\PropertyDescription;
use LazyJsonMapper\Property\PropertyMapCache;
use LazyJsonMapper\Property\PropertyMapCompiler;
use LazyJsonMapper\Utilities;

/**
 * Automatic LazyJsonMapper-class documentation generator.
 *
 * @copyright 2017 The LazyJsonMapper Project
 * @license http://www.apache.org/licenses/LICENSE-2.0
 * @author SteveJobzniak (https://github.com/SteveJobzniak)
 */
class LazyClassDocumentor
{
    /** @var PropertyMapCache */
    private static $_propertyMapCache;

    /** @var array */
    private $_compiledPropertyMapLink;

    /** @var ReflectionClass */
    private $_reflector;

    /** @var array */
    private $_options;

    /** @var string Newline sequence. */
    private $_nl;

    /**
     * Constructor.
     *
     * @param string $class
     * @param array  $options
     *
     * @throws ReflectionException
     */
    public function __construct(
        $class,
        array $options)
    {
        if (self::$_propertyMapCache === null) {
            self::$_propertyMapCache = new PropertyMapCache();
        }
        $this->_reflector = new ReflectionClass($class);
        $this->_options = $options;
        $this->_nl = $options['windows'] ? "\r\n" : "\n";
    }

    /**
     * Process the current class.
     *
     * @throws ReflectionException
     * @throws LazyJsonMapperException
     *
     * @return bool `TRUE` if on-disk file has correct docs, otherwise `FALSE`.
     */
    public function process()
    {
        // Only process user-defined classes (never any built-in PHP classes).
        if (!$this->_reflector->isUserDefined()) {
            return true;
        }

        // There's nothing to do if this isn't a LazyJsonMapper subclass.
        // NOTE: This properly skips "\LazyJsonMapper\LazyJsonMapper" itself.
        if (!$this->_reflector->isSubclassOf('\LazyJsonMapper\LazyJsonMapper')) {
            return true;
        }

        // Compile this class property map if not yet built and cached.
        $thisClassName = $this->_reflector->getName();
        if (!isset(self::$_propertyMapCache->classMaps[$thisClassName])) {
            try {
                PropertyMapCompiler::compileClassPropertyMap( // Throws.
                    self::$_propertyMapCache,
                    $thisClassName
                );
            } catch (Exception $e) {
                fwrite(STDERR, '> Unable to compile the class property map for "'.$thisClassName.'". Reason: '.$e->getMessage().PHP_EOL);

                return false;
            }
        }

        // Now link to the property map cache for our current class.
        $this->_compiledPropertyMapLink = &self::$_propertyMapCache->classMaps[$thisClassName];

        // Get the current class comment (string if ok, FALSE if none exists).
        $currentDocComment = $this->_reflector->getDocComment();
        if (is_string($currentDocComment)) {
            $currentDocComment = trim($currentDocComment);
        }

        // Extract all relevant lines from the current comment.
        $finalDocLines = $this->_extractRelevantLines($currentDocComment);

        // Generate the automatic summary line (classname followed by period).
        $autoSummaryLine = $this->_reflector->getShortName().'.';

        // If the 1st line is a classname followed by a period, update the name.
        // NOTE: This ensures that we update all outdated auto-added classnames,
        // and the risk of false positives is very low since we only document
        // `LazyJsonMapper`-based classes with a `OneWord.`-style summary line.
        // NOTE: Regex is from http://php.net/manual/en/language.oop5.basic.php,
        // and yes we must run it in NON-UNICODE MODE, so that it parses on a
        // byte by byte basis exactly like the real PHP classname interpreter.
        if (
            isset($finalDocLines[0]) // The 1st line MUST exist to proceed.
            && preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*\.$/', $finalDocLines[0])
        ) {
            $finalDocLines[0] = $autoSummaryLine;
        }

        // Generate the magic documentation lines for the current class.
        $magicDocLines = $this->_generateMagicDocs();
        if (!empty($magicDocLines)) {
            // If there are no lines already... add the automatic summary line.
            if (empty($finalDocLines)) {
                $finalDocLines[] = $autoSummaryLine;
            }

            // Check the 1st char of the 1st line. If it's an @tag of any kind,
            // insert automatic summary line at top and empty line after that.
            elseif ($finalDocLines[0][0] === '@') {
                array_unshift(
                    $finalDocLines,
                    $autoSummaryLine,
                    ''
                );
            }

            $finalDocLines[] = ''; // Add empty line before our magic docs.
            $finalDocLines = array_merge($finalDocLines, array_values($magicDocLines));
        }
        unset($magicDocLines);

        // Generate the final doc-comment that this class is supposed to have.
        if (!empty($finalDocLines)) {
            // This will generate even if the class only contained an existing
            // summary/tags and nothing was added by our magic handler.
            foreach ($finalDocLines as &$line) {
                $line = ($line === '' ? ' *' : " * {$line}");
            }
            unset($line);
            $finalDocComment = sprintf(
                '/**%s%s%s */',
                $this->_nl,
                implode($this->_nl, $finalDocLines),
                $this->_nl
            );
        } else {
            // The FALSE signifies that we want no class doc-block at all...
            $finalDocComment = false;
        }
        unset($finalDocLines);

        // There's nothing to do if the doc-comment is already correct.
        // NOTE: Both values are FALSE if no doc-comment exists and none wanted.
        if ($currentDocComment === $finalDocComment) {
            return true;
        }

        // The docs mismatch. If this is a validate-run, just return false now.
        if ($this->_options['validate-only']) {
            fwrite(STDERR, '> Outdated class docs encountered in "'.$thisClassName.'". Aborting scan...'.PHP_EOL);

            return false;
        }

        // Load the contents of the file...
        $classFileName = $this->_reflector->getFileName();
        $fileLines = @file($classFileName);
        if ($fileLines === false) {
            fwrite(STDERR, '> Unable to read class file from disk: "'.$classFileName.'".'.PHP_EOL);

            return false;
        }

        // Split the file into lines BEFORE the class and lines AFTER the class.
        $classLine = $this->_reflector->getStartLine();
        $startLines = array_slice($fileLines, 0, $classLine - 1);
        $endLines = array_slice($fileLines, $classLine - 1);
        unset($fileLines);

        // Insert the new class documentation using a very careful algorithm.
        if ($currentDocComment !== false) {
            // Since the class already had PHPdoc, remove it and insert new doc.
            // NOTE: A valid PHPdoc (getDocComment()) always starts with
            // "/**[whitespace]". If it's just a "/*" or something like
            // "/**Foo", then it's not detected by getDocComment(). However, the
            // comment may be several lines above the class. So we'll have to do
            // an intelligent search to find the old class-comment. As for the
            // ending tag "*/", PHP doesn't care about whitespace around that.
            // And it also doesn't let the user escape the "*/", which means
            // that if we see that sequence we KNOW it's the end of a comment!
            // NOTE: We'll search for the latest "/**[whitespace]" block and
            // remove all lines from that until its closest "*/".
            $deleteFrom = null;
            $deleteTo = null;
            for ($i = count($startLines) - 1; $i >= 0; --$i) {
                if (strpos($startLines[$i], '*/') !== false) {
                    $deleteTo = $i;
                }
                if (preg_match('/^\s*\/\*\*\s/u', $startLines[$i])) {
                    $deleteFrom = $i;
                    break;
                }
            }

            // Ensure that we have found valid comment-offsets.
            if ($deleteFrom === null || $deleteTo === null || $deleteTo < $deleteFrom) {
                fwrite(STDERR, '> Unable to parse current class comment on disk: "'.$classFileName.'".'.PHP_EOL);

                return false;
            }

            // Now update the startLines array to replace the doc-comment...
            foreach ($startLines as $k => $v) {
                if ($k === $deleteFrom && $finalDocComment !== false) {
                    // We've found the first line of the old comment, and we
                    // have a new comment. So replace that array entry.
                    $startLines[$k] = $finalDocComment.$this->_nl;
                } elseif ($k >= $deleteFrom && $k <= $deleteTo) {
                    // Delete all other comment lines, including the first line
                    // if we had no new doc-comment.
                    unset($startLines[$k]);
                }

                // Break if we've reached the final line to delete.
                if ($k >= $deleteTo) {
                    break;
                }
            }
        } elseif ($finalDocComment !== false) {
            // There's no existing doc-comment. Just add ours above the class.
            // NOTE: This only does something if we had a new comment to insert,
            // which we SHOULD have since we came this far in this scenario...
            $startLines[] = $finalDocComment.$this->_nl;
        }

        // Generate the new file contents.
        $newFileContent = implode($startLines).implode($endLines);
        unset($startLines);
        unset($endLines);

        // Perform an atomic file-write to disk, which ensures that we will
        // never be able to corrupt the class-files on disk via partial writes.
        $written = Utilities::atomicWrite($classFileName, $newFileContent);
        if ($written !== false) {
            echo '> Wrote updated class documentation to disk: "'.$classFileName.'".'.PHP_EOL;

            return true;
        } else {
            fwrite(STDERR, '> Unable to write new class documentation to disk: "'.$classFileName.'".'.PHP_EOL);

            return false;
        }
    }

    /**
     * Extracts all relevant lines from a doc-comment.
     *
     * @param string $currentDocComment
     *
     * @return array
     */
    private function _extractRelevantLines(
        $currentDocComment)
    {
        if (!is_string($currentDocComment)) {
            return [];
        }

        // Remove the leading and trailing doc-comment tags (/** and */).
        $currentDocComment = preg_replace('/(^\s*\/\*\*\s*|\s*\*\/$)/u', '', $currentDocComment);

        // Process all lines. Skip all @method and @property lines.
        $relevantLines = [];
        $lines = preg_split('/\r?\n|\r/u', $currentDocComment);
        foreach ($lines as $line) {
            // Remove leading and trailing whitespace, and leading asterisks.
            $line = trim(preg_replace('/^\s*\*+/u', '', $line));

            // Skip this line if it's a @method or @property line.
            // NOTE: Removing them is totally safe, because the LazyJsonMapper
            // class has marked all of its magic property/function handlers as
            // final, which means that people's subclasses CANNOT override them
            // to add their own magic methods/properties. So therefore we KNOW
            // that ALL existing @method/@property class doc lines belong to us!
            if (preg_match('/^@(?:method|property)/u', $line)) {
                continue;
            }

            $relevantLines[] = $line;
        }

        // Remove trailing empty lines from the relevant lines.
        for ($i = count($relevantLines) - 1; $i >= 0; --$i) {
            if ($relevantLines[$i] === '') {
                unset($relevantLines[$i]);
            } else {
                break;
            }
        }

        // Remove leading empty lines from the relevant lines.
        foreach ($relevantLines as $k => $v) {
            if ($v !== '') {
                break;
            }

            unset($relevantLines[$k]);
        }

        // Return a re-indexed (properly 0-indexed) array.
        return array_values($relevantLines);
    }

    /**
     * Generate PHPdoc lines for all magic properties and functions.
     *
     * @throws ReflectionException
     * @throws LazyJsonMapperException
     *
     * @return array
     */
    private function _generateMagicDocs()
    {
        // Check whether we should (and can) document properties and functions.
        $documentProperties = $this->_options['properties'] && $this->_reflector->getConstant('ALLOW_VIRTUAL_PROPERTIES');
        $documentFunctions = $this->_options['functions'] && $this->_reflector->getConstant('ALLOW_VIRTUAL_FUNCTIONS');
        if (!$documentProperties && !$documentFunctions) {
            return [];
        }

        // Export all JSON properties, with RELATIVE class-paths when possible.
        // NOTE: We will document ALL properties. Even ones inherited from
        // parents/imported maps. This ensures that users who are manually
        // reading the source code can see EVERYTHING without needing an IDE.
        $properties = [];
        $ownerClassName = $this->_reflector->getName();
        foreach ($this->_compiledPropertyMapLink as $propName => $propDef) {
            $properties[$propName] = new PropertyDescription( // Throws.
                $ownerClassName,
                $propName,
                $propDef,
                true // Use relative class-paths when possible.
            );
        }

        // Build the magic documentation...
        $magicDocLines = [];
        foreach (['functions', 'properties'] as $docType) {
            if (($docType === 'functions' && !$documentFunctions)
                || ($docType === 'properties' && !$documentProperties)) {
                continue;
            }

            // Generate all lines for the current magic tag type...
            $lineStorage = [];
            foreach ($properties as $property) {
                if ($docType === 'functions') {
                    // We will only document useful functions (not the "has",
                    // since those are useless for properties that are fully
                    // defined in the class map).
                    foreach (['get', 'set', 'is', 'unset'] as $funcType) {
                        // Generate the function name, ie "getSomething", and
                        // skip this function if it's already defined as a REAL
                        // (overridden) function in this class or its parents.
                        $functionName = $funcType.$property->func_case;
                        if (!$this->_options['document-overridden'] && $this->_reflector->hasMethod($functionName)) {
                            continue;
                        }

                        // Alright, the function doesn't exist as a real class
                        // function, or the user wants to document it anyway...
                        // Document it via its calculated signature.
                        // NOTE: Classtypes use paths relative to current class!
                        $functionSignature = $property->{'function_'.$funcType};
                        $lineStorage[$functionName] = sprintf('@method %s', $functionSignature);
                    }
                } elseif ($docType === 'properties') {
                    // Skip this property if it's already defined as a REAL
                    // (overridden) property in this class or its parents.
                    if (!$this->_options['document-overridden'] && $this->_reflector->hasProperty($property->name)) {
                        continue;
                    }

                    // Alright, the property doesn't exist as a real class
                    // property, or the user wants to document it anyway...
                    // Document it via its calculated signature.
                    // NOTE: Classtypes use paths relative to current class!
                    $lineStorage[$property->name] = sprintf(
                        '@property %s $%s',
                        $property->type,
                        $property->name
                    );
                }
            }

            // Skip this tag type if there was nothing to document...
            if (empty($lineStorage)) {
                continue;
            }

            // Insert empty line separators between different magic tag types.
            if (!empty($magicDocLines)) {
                $magicDocLines[] = '';
            }

            // Reorder lines by name and add them to the magic doc lines.
            // NOTE: We use case sensitivity so that "getComments" and
            // "getCommentThreads" etc aren't placed next to each other.
            ksort($lineStorage, SORT_NATURAL); // Case-sensitive natural order.
            $magicDocLines = array_merge($magicDocLines, array_values($lineStorage));
        }

        return $magicDocLines;
    }
}

// Now process all PHP files under all of the project's namespace folders.
foreach ($namespaces as $realpath => $namespace) {
    echo PHP_EOL.'Processing namespace "'.$namespace.'".'.PHP_EOL.'- Path: "'.$realpath.'".'.PHP_EOL;
    $realpathlen = strlen($realpath);

    $iterator = new RegexIterator(
        new RecursiveIteratorIterator(new RecursiveDirectoryIterator($realpath)),
        '/\.php$/i', RecursiveRegexIterator::GET_MATCH
    );
    foreach ($iterator as $file => $ext) {
        // Determine the real path to the file (compatible with $realpath).
        $realfile = realpath($file);
        if ($realfile === false) {
            fwrite(STDERR, 'Unable to determine real path to file "'.$file.'".'.PHP_EOL);
            exit(1);
        }

        // Now ensure that the file starts with the expected path...
        if (strncmp($realpath, $realfile, $realpathlen) !== 0) {
            fwrite(STDERR, 'Unexpected path to file "'.$realfile.'". Does not match project path.'.PHP_EOL);
            exit(1);
        }
        $class = substr($realfile, $realpathlen);

        // Remove the leading slash for the folder...
        if ($class[0] !== '/' && $class[0] !== '\\') {
            fwrite(STDERR, 'Unexpected path to file "'.$realfile.'". Does not match project path.'.PHP_EOL);
            exit(1);
        }
        $class = substr($class, 1);

        // And now just generate the final class name...
        $class = sprintf(
            '%s%s',
            $namespace,
            str_replace('/', '\\', preg_replace('/\.php$/ui', '', $class))
        );

        // Some files may not contain classes. For example, some people have
        // functions.php files with functions, etc. So before we proceed, just
        // ensure that the generated class name actually exists.
        // NOTE: class_exists() ignores interfaces. Only finds classes. Good.
        if (!class_exists($class, true)) { // TRUE = Autoload.
            continue;
        }

        // Now process the current class.
        $documentor = new LazyClassDocumentor($class, $options);
        $result = $documentor->process();
        if (!$result) {
            if ($options['validate-only']) {
                fwrite(STDERR, '> One or more files need updated class documentation or contain other errors.'.PHP_EOL);
            } else {
                fwrite(STDERR, '> Error while processing class "'.$class.'". Aborting...'.PHP_EOL);
            }
            exit(1);
        }
    }
}
