artabro/wire/core/FileCompiler.php
2024-08-27 11:35:37 +02:00

1205 lines
36 KiB
PHP

<?php namespace ProcessWire;
/**
* FileCompiler
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
* @todo determine whether we should make storage in dedicated table rather than using wire('cache').
* @todo handle race conditions for multiple requests attempting to compile the same file(s).
*
* @method string compile($sourceFile)
* @method string compileData($data, $sourceFile)
*
*/
class FileCompiler extends Wire {
/**
* Compilation options for this FileCompiler instance
*
* @var array
*
*/
protected $options = array(
'includes' => true, // compile include()'d files too?
'namespace' => true, // compile to make compatible with PW namespace when necessary?
'modules' => false, // compile using installed FileCompiler modules
'skipIfNamespace' => false, // skip compiled file if original declares a namespace? (note: file still compiled, but not used)
);
/**
* Options for ALL FileCompiler instances
*
* Values shown below are for reference only as the get overwritten by $config->fileCompilerOptions at runtime.
*
* @var array
*
*/
protected $globalOptions = array(
'siteOnly' => false, // only allow compilation of files in /site/ directory
'showNotices' => true, // show notices about compiled files to superuser
'logNotices' => true, // log notices about compiled files and maintenance to file-compiler.txt log.
'chmodFile' => '', // mode to use for created files, i.e. "0644"
'chmodDir' => '', // mode to use for created directories, i.e. "0755"
'exclusions' => array(), // exclude files or paths that start with any of these (gets moved to $this->exclusions array)
'extensions' => array('php', 'module', 'inc'), // file extensions we compile (gets moved to $this->extensions array)
'cachePath' => '', // path where compiled files are stored (default is /site/assets/cache/FileCompiler/, moved to $this->cachePath)
);
/**
* Path to source files directory
*
* @var string
*
*/
protected $sourcePath;
/**
* Path to compiled files directory
*
* @var string
*
*/
protected $targetPath = null;
/**
* Path to root of compiled files directory (upon which targetPath is based)
*
* Set via the $config->fileCompilerOptions['cachePath'] setting.
*
* @var string
*
*/
protected $cachePath;
/**
* Files or directories that should be excluded from compilation
*
* @var array
*
*/
protected $exclusions = array();
/**
* File extensions that we compile and copy
*
* @var array
*
*/
protected $extensions = array(
'php',
'module',
'inc',
);
/**
* Detected file namespace (during compileData)
*
* @var string
*
*/
protected $ns = '';
/**
* String with raw PHP blocks only, and with any quoted values removed.
*
* @var string
*
*/
protected $rawPHP = '';
/**
* Same as raw PHP but with all quoted values converted to literal "string"
*
* @var string
*
*/
protected $rawDequotedPHP = '';
/**
* Construct
*
* @param string $sourcePath Path where source files are located
* @param array $options Indicate which compilations should be performed (default='includes' and 'namespace')
*
*/
public function __construct($sourcePath, array $options = array()) {
$this->options = array_merge($this->options, $options);
if(strpos($sourcePath, '..') !== false) $sourcePath = realpath($sourcePath);
if(DIRECTORY_SEPARATOR != '/') $sourcePath = str_replace(DIRECTORY_SEPARATOR, '/', $sourcePath);
$this->sourcePath = rtrim($sourcePath, '/') . '/';
parent::__construct();
}
/**
* Wired to instance
*
*/
public function wired() {
$config = $this->wire()->config;
$globalOptions = $config->fileCompilerOptions;
if(is_array($globalOptions)) {
$this->globalOptions = array_merge($this->globalOptions, $globalOptions);
}
if(!empty($this->globalOptions['extensions'])) {
$this->extensions = $this->globalOptions['extensions'];
}
if(empty($this->globalOptions['cachePath'])) {
$this->cachePath = $config->paths->cache . $this->className() . '/';
} else {
$this->cachePath = rtrim($this->globalOptions['cachePath'], '/') . '/';
}
if(!strlen(__NAMESPACE__)) {
// when PW compiled without namespace support
$this->options['skipIfNamespace'] = false;
$this->options['namespace'] = true;
}
parent::wired();
}
/**
* Initialize paths
*
* @throws WireException
*
*/
protected function init() {
if(!$this->isWired()) $this->wired();
static $preloaded = false;
$config = $this->wire()->config;
if(!$preloaded) {
$this->wire()->cache->preloadFor($this);
$preloaded = true;
}
if(!empty($this->globalOptions['exclusions'])) {
$this->exclusions = $this->globalOptions['exclusions'];
}
$this->addExclusion($config->paths->wire);
$rootPath = $config->paths->root;
$targetPath = $this->cachePath;
if(strpos($this->sourcePath, $targetPath) === 0) {
// sourcePath is inside the targetPath, correct this
$this->sourcePath = str_replace($targetPath, '', $this->sourcePath);
$this->sourcePath = $rootPath . $this->sourcePath;
}
$t = str_replace($rootPath, '', $this->sourcePath);
if(DIRECTORY_SEPARATOR != '/' && strpos($t, ':')) $t = str_replace(':', '', $t);
$this->targetPath = $targetPath . trim($t, '/') . '/';
$this->ns = '';
}
/**
* Make a directory with proper permissions
*
* @param string $path Path of directory to create
* @param bool $recursive Default is true
* @return bool
*
*/
protected function mkdir($path, $recursive = true) {
$chmod = $this->globalOptions['chmodDir'];
if(empty($chmod) || !is_string($chmod) || strlen($chmod) < 2) $chmod = null;
return $this->wire()->files->mkdir($path, $recursive, $chmod);
}
/**
* Change file to correct mode for FileCompiler
*
* @param string $filename
* @return bool
*
*/
protected function chmod($filename) {
$chmod = $this->globalOptions['chmodFile'];
if(empty($chmod) || !is_string($chmod) || strlen($chmod) < 2) $chmod = null;
return $this->wire()->files->chmod($filename, false, $chmod);
}
/**
* Initialize the target path, making sure that it exists and creating it if not
*
* @throws WireException
*
*/
protected function initTargetPath() {
if(!is_dir($this->targetPath)) {
if(!$this->mkdir($this->targetPath)) {
throw new WireException("Unable to create directory $this->targetPath");
}
}
}
/**
* Populate the $this->rawPHP data which contains only raw php without quoted values
*
* @param string $data
*
*/
protected function initRawPHP(&$data) {
$this->rawPHP = '';
$this->rawDequotedPHP = '';
$phpOpen = '<' . '?';
$phpClose = '?' . '>';
$phpBlocks = explode($phpOpen, $data);
foreach($phpBlocks as $phpBlock) {
$pos = strpos($phpBlock, $phpClose);
if($pos !== false) {
$closeBlock = substr($phpBlock, strlen($phpClose) + 2);
if(strrpos($closeBlock, '{') && strrpos($closeBlock, '}') && strrpos($closeBlock, '=')
&& strrpos($closeBlock, '(') && strrpos($closeBlock, ')')
&& preg_match('/\sif\s*\(/', $closeBlock)
&& preg_match('/\$[_a-zA-Z][_a-zA-Z0-9]+/', $closeBlock)) {
// closeBlock still looks a lot like PHP, leave $phpBlock as-is
// happens when for example a phpClose is within a PHP string
} else {
$phpBlock = substr($phpBlock, 0, $pos);
}
}
$this->rawPHP .= $phpOpen . $phpBlock . $phpClose . "\n";
}
// remove docblocks/comments
// $this->rawPHP = preg_replace('!/\*.+?\*/!s', '', $this->rawPHP);
// remove escaped quotes
$this->rawDequotedPHP = str_replace(array('\\"', "\\'"), '', $this->rawPHP);
// remove double quoted blocks
$this->rawDequotedPHP = preg_replace('/([\s(.=,])"[^"]*"/s', '$1"string"', $this->rawDequotedPHP);
// remove single quoted blocks
$this->rawDequotedPHP = preg_replace('/([\s(.=,])\'[^\']*\'/s', '$1\'string\'', $this->rawDequotedPHP);
}
/**
* Allow the given filename to be compiled?
*
* @param string $filename Full path and filename to compile (this property can be modified by the function).
* @param string $basename Just the basename (this property can be modified by the function).
* @return bool
*
*/
protected function allowCompile(&$filename, &$basename) {
if($this->globalOptions['siteOnly']) {
// only files in /site/ are allowed for compilation
if(strpos($filename, $this->wire()->config->paths->site) !== 0) {
// sourcePath is somewhere outside of the PW /site/, and not allowed
return false;
}
}
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if(!in_array(strtolower($ext), $this->extensions)) {
if(!strlen($ext) && !is_file($filename)) {
foreach($this->extensions as $ext) {
if(is_file("$filename.$ext")) {
// assume PHP file extension if none given, for cases like wireIncludeFile
$filename .= ".$ext";
$basename .= ".$ext";
}
}
} else {
return false;
}
}
if(!is_file($filename)) {
return false;
}
$allow = true;
foreach($this->exclusions as $pathname) {
if(strpos($filename, $pathname) === 0) {
$allow = false;
break;
}
}
return $allow;
}
/**
* Compile given source file and return compiled destination file
*
* @param string $sourceFile Source file to compile (relative to sourcePath given in constructor)
* @return string Full path and filename of compiled file. Returns sourceFile is compilation is not necessary.
* @throws WireException if given invalid sourceFile
*
*/
public function ___compile($sourceFile) {
$this->init();
if(strpos($sourceFile, $this->sourcePath) === 0) {
$sourcePathname = $sourceFile;
$sourceFile = str_replace($this->sourcePath, '/', $sourceFile);
} else {
$sourcePathname = $this->sourcePath . ltrim($sourceFile, '/');
}
if(!$this->allowCompile($sourcePathname, $sourceFile)) return $sourcePathname;
$this->initTargetPath();
$cacheName = md5($sourcePathname);
$sourceHash = md5_file($sourcePathname);
$targetHash = '';
$targetPathname = $this->targetPath . ltrim($sourceFile, '/');
$compileNow = true;
if(is_file($targetPathname)) {
// target file already exists, check if it is up-to-date
// $targetData = file_get_contents($targetPathname);
$targetHash = md5_file($targetPathname);
$cache = $this->wire()->cache->getFor($this, $cacheName);
if($cache && is_array($cache)) {
if($cache['target']['hash'] == $targetHash && $cache['source']['hash'] == $sourceHash) {
// target file is up-to-date
$compileNow = false;
} else {
// target file changed somewhere else, needs to be re-compiled
$this->wire()->cache->deleteFor($this, $cacheName);
}
if(!$compileNow && isset($cache['source']['ns'])) {
$this->ns = $cache['source']['ns'];
}
}
}
if($compileNow) {
$sourcePath = dirname($sourcePathname);
$targetPath = dirname($targetPathname);
$targetData = file_get_contents($sourcePathname);
if(stripos($targetData, 'FileCompiler=0')) return $sourcePathname; // bypass if it contains this string
if(strpos($targetData, 'namespace') !== false) $this->ns = $this->wire()->files->getNamespace($targetData, true);
if(!$this->ns) $this->ns = "\\";
if(!__NAMESPACE__ && !$this->options['modules'] && $this->ns === "\\") return $sourcePathname;
set_time_limit(120);
$this->copyAllNewerFiles($sourcePath, $targetPath);
$targetDirname = dirname($targetPathname) . '/';
if(!is_dir($targetDirname)) $this->mkdir($targetDirname);
$targetData = $this->compileData($targetData, $sourcePathname);
if(false !== file_put_contents($targetPathname, $targetData, LOCK_EX)) {
$this->chmod($targetPathname);
$this->touch($targetPathname, filemtime($sourcePathname));
$targetHash = md5_file($targetPathname);
$cacheData = array(
'source' => array(
'file' => $sourcePathname,
'hash' => $sourceHash,
'size' => filesize($sourcePathname),
'time' => filemtime($sourcePathname),
'ns' => $this->ns,
),
'target' => array(
'file' => $targetPathname,
'hash' => $targetHash,
'size' => filesize($targetPathname),
'time' => filemtime($targetPathname),
)
);
$this->wire()->cache->saveFor($this, $cacheName, $cacheData, WireCache::expireNever);
}
}
// if source and target are identical, use the source file
if($targetHash && $sourceHash === $targetHash) {
return $sourcePathname;
}
// show notices about compiled files, when applicable
if($compileNow) {
$message = $this->_('Compiled file:') . ' ' . str_replace($this->wire()->config->paths->root, '/', $sourcePathname);
if($this->globalOptions['showNotices']) {
$u = $this->wire('user');
if($u && $u->isSuperuser()) $this->message($message);
}
if($this->globalOptions['logNotices']) {
$this->log($message);
}
}
// if source file declares a namespace and skipIfNamespace option in use, use source file
if($this->options['skipIfNamespace'] && $this->ns && $this->ns != "\\") return $sourcePathname;
return $targetPathname;
}
/**
* Compile the given string of data
*
* @param string $data
* @param string $sourceFile
* @return string
*
*/
protected function ___compileData($data, $sourceFile) {
if($this->options['skipIfNamespace'] && $this->ns && $this->ns !== "\\") {
// file already declares a namespace and options indicate we shouldn't compile
return $data;
}
$this->initRawPHP($data);
if($this->options['includes']) {
$dataHash = md5($data);
$this->compileIncludes($data, $sourceFile);
if(md5($data) != $dataHash) $this->initRawPHP($data);
}
if($this->options['namespace']) {
if(__NAMESPACE__) {
if($this->ns && $this->ns !== "\\") {
// namespace already present, no need for namespace compilation
} else {
$this->compileNamespace($data);
}
} else {
if($this->ns && $this->ns !== "\\") {
// namespace present in file
$this->compileNamespace($data);
}
}
}
if($this->options['modules']) {
// FileCompiler modules
$compilers = array();
foreach($this->wire()->modules->findByPrefix('FileCompiler', true) as $module) {
if(!$module instanceof FileCompilerModule) continue;
$runOrder = (int) $module->get('runOrder');
while(isset($compilers[$runOrder])) $runOrder++;
$compilers[$runOrder] = $module;
}
if(count($compilers)) {
ksort($compilers);
foreach($compilers as $module) {
/** @var FileCompilerModule $module */
$module->setSourceFile($sourceFile);
$data = $module->compile($data);
}
}
}
if(!strlen(__NAMESPACE__)) {
if(strpos($this->rawPHP, "ProcessWire\\")) {
$data = str_replace(array("\\ProcessWire\\", "ProcessWire\\"), "\\", $data);
}
}
if(stripos($data, "FileCompiler=?") !== false) {
// Allow for a token that gets replaced so a file can detect if it's compiled
$data = str_replace("FileCompiler=?", "FileCompiler=Yes", $data);
}
return $data;
}
/**
* Compile comments so that they can be easily identified by other compiler methods
*
* @todo this is a work in progress, not yet in use
*
* @param $data
*
*/
protected function compileComments(&$data) {
$inComment = false;
$inPHP = false;
$lines = explode("\n", $data);
$numChanges = 0;
$commentIdentifier = '!PWFC!';
foreach($lines as $key => $line) {
$_line = $line; // original
$phpOpen = strrpos($line, '<' . '?');
$phpClose = strrpos($line, '?' . '>');
if($inPHP) {
if($phpClose !== false && ($phpClose === 0 || $phpClose > (int) $phpOpen)) {
$inPHP = false;
}
} else {
if($phpOpen !== false && ($phpClose === false || $phpClose < $phpOpen)) {
$inPHP = true;
}
}
if(!$inPHP) continue;
$commentOpen = strpos($line, '/' . '*');
$commentClose = strpos($line, '*' . '/');
if($inComment) {
if($commentClose !== false && ($commentOpen === false || $commentOpen < $commentClose)) {
$inComment = false;
}
$line = $commentIdentifier . $line;
}
if($commentOpen !== false) {
// has an open comment
if($commentClose !== false) {
// has a close comment, skip this line
continue;
} else {
$inComment = true;
}
}
if($line !== $_line) {
$lines[$key] = $line;
$numChanges++;
}
}
if($numChanges) {
$data = implode("\n", $lines);
}
}
/**
* Compile include(), require() (and variations) to refer to compiled files where possible
*
* @param string $data
* @param string $sourceFile
*
*/
protected function compileIncludes(&$data, $sourceFile) {
// other related to includes
$rawPHP = $this->rawPHP;
if(strpos($rawPHP, '__DIR__') !== false) {
$data = str_replace('__DIR__', "'" . dirname($sourceFile) . "'", $data);
$rawPHP = str_replace('__DIR__', "'" . dirname($sourceFile) . "'", $rawPHP);
}
if(strpos($rawPHP, '__FILE__') !== false) {
$data = str_replace('__FILE__', "'" . $sourceFile . "'", $data);
$rawPHP = str_replace('__FILE__', "'" . $sourceFile . "'", $rawPHP);
}
$optionsStr = $this->optionsToString($this->options);
$funcs = array(
'include_once',
'include',
'require_once',
'require',
'wireIncludeFile',
'wireRenderFile',
'TemplateFile',
);
// main include regex
$re = '/^' .
'(.*?)' . // 1: open
'(' . implode('|', $funcs) . ')' . // 2:function
'([\( ]+)' . // 3: argOpen: open parenthesis and/or space
'(["\']?[^;\r\n]+)' . // 4:filename, and rest of the statement (file may be quoted or end with closing parens)
'([;\r\n])' . // 5:close, whatever the last character is on the line
'/im';
if(!preg_match_all($re, $rawPHP, $matches)) return;
foreach($matches[0] as $key => $fullMatch) {
// if the include statement looks like one of these below then skip compilation for included file
// include(/*NoCompile*/__DIR__ . '/file.php');
// include(__DIR__ . '/file.php'/*NoCompile*/);
if(strpos($fullMatch, 'NoCompile') !== false) continue;
$open = $matches[1][$key];
$funcMatch = $matches[2][$key];
$argOpen = trim($matches[3][$key]);
$fileMatch = $matches[4][$key];
$close = $matches[5][$key];
$argsMatch = '';
if(!$argOpen && strpos($funcMatch, 'include') !== 0 && strpos($funcMatch, 'require') !== 0) {
// only include, include_once, require, require_once can be used without opening parenthesis
continue;
}
$fileMatchType = $this->compileIncludesFileMatchType($fileMatch, $funcMatch);
if(!$fileMatchType) continue;
if(!$this->compileIncludesValidLineOpen($open)) continue;
if(strpos($fileMatch, '?' . '>')) {
// move closing PHP tag out of the fileMatch and into the close
list($fileMatch, $fileMatchExtra) = explode('?' . '>', $fileMatch);
$close = '?' . '>' . $fileMatchExtra . $close;
$fileMatch = trim($fileMatch);
}
if(substr($fileMatch, -1) == ')') {
// move the closing parenthesis out of fileMatch and into close
$fileMatch = substr($fileMatch, 0, -1);
$close = ")$close";
}
if(empty($fileMatch)) continue;
if(empty($argOpen)) {
// if there was no opening "(", compiler will be adding one, so we'll need an additional corresponding ")"
$close = ")$close";
}
$commaPos = strpos($fileMatch, ',');
if($commaPos) {
// fileMatch contains additional function arguments
$argsMatch = substr($fileMatch, $commaPos);
$fileMatch = substr($fileMatch, 0, $commaPos);
}
if(strpos($fileMatch, '"') === 0 || strpos($fileMatch, "'") === 0) {
// fileMatch is quoted string
if(strpos($fileMatch, './') === 1) {
// relative to current dir, convert to absolute
$fileMatch = $fileMatch[0] . dirname($sourceFile) . substr($fileMatch, 2);
} else if(strpos($fileMatch, '/') === false
&& strpos($fileMatch, '$') === false
&& strpos($fileMatch, '(') === false
&& strpos($fileMatch, '\\') === false) {
// i.e. include("file.php")
$fileMatch = $fileMatch[0] . dirname($sourceFile) . '/' . substr($fileMatch, 1);
}
}
$fileMatch = str_replace("\t", '', $fileMatch);
if(strlen($open)) $open .= ' ';
$ns = __NAMESPACE__ ? "\\ProcessWire" : "";
$open = rtrim($open) . ' ';
$newFullMatch = "$open$funcMatch($ns\\wire('files')->compile($fileMatch,$optionsStr)$argsMatch$close";
$data = str_replace($fullMatch, $newFullMatch, $data);
}
// replace absolute root path references with runtime generated versions
$rootPath = $this->wire()->config->paths->root;
if(strpos($data, $rootPath)) {
$ns = __NAMESPACE__ ? "\\ProcessWire" : "";
$data = preg_replace('%([\'"])' . preg_quote($rootPath) . '([^\'"\s\r\n]*[\'"])%',
$ns . '\\wire("config")->paths->root . $1$2',
$data);
}
}
/**
* Test the given line $open preceding an include statement for validity
*
* @param string $open
* @return bool Returns true if valid, false if not
*
*/
protected function compileIncludesValidLineOpen($open) {
if(!strlen($open)) return true;
$skipMatch = false;
$test = $open;
foreach(array('"', "'") as $quote) {
// skip when words like "require" are in a string
if(strpos($test, $quote) === false) continue;
$test = str_replace('\\' . $quote, '', $test); // ignore quotes that are escaped
if(strpos($test, $quote) === false) continue;
if(substr_count($test, $quote) % 2 > 0) {
// there are an uneven number of quotes, indicating that
// our $funcMatch is likely part of a quoted string
$skipMatch = true;
break;
}
if($quote == '"' && strpos($test, "'") !== false) {
// remove quoted apostrophes so they don't confuse the next iteration
$test = preg_replace('/"[^"\']*\'[^"]*"/', '', $test);
}
}
if(!$skipMatch && preg_match('/^[$_a-zA-Z0-9]+$/', substr($open, -1))) {
// skip things like: something_include(... and $include
$skipMatch = true;
}
return $skipMatch ? false : true;
}
/**
* Returns fileMatch type of 'var', 'file', 'func' or boolean false if not valid
*
* @param string $fileMatch The $fileMatch var from compileIncludes() method
* @param string $funcMatch include function name
* @return string|bool
*
*/
protected function compileIncludesFileMatchType($fileMatch, $funcMatch) {
$fileMatch = trim($fileMatch);
$isValid = false;
$phpVarSign = strpos($fileMatch, '$');
$doubleQuote1 = strpos($fileMatch, '"');
$doubleQuote2 = strrpos($fileMatch, '"');
$singleQuote1 = strpos($fileMatch, "'");
$singleQuote2 = strrpos($fileMatch, "'");
$parenthesis1 = strpos($fileMatch, '(');
$parenthesis2 = strrpos($fileMatch, ')');
$testFile = '';
if($phpVarSign === 0) {
// fileMatch starts with a var name, make sure it at least starts in PHP var format
if(preg_match('/^\$[_a-zA-Z]/', $fileMatch)) $isValid = 'var';
} else if($doubleQuote1 !== false && $doubleQuote2 > $doubleQuote1) {
// fileMatch has both open and close double quotes with possibly a filename, so validate extension
$testFile = substr($fileMatch, $doubleQuote1 + 1, $doubleQuote2 - $doubleQuote1 - 1);
} else if($singleQuote1 !== false && $singleQuote2 > $singleQuote1) {
// fileMatch has both open and close single quotes with possibly a filename, so validate extension
$testFile = substr($fileMatch, $singleQuote1 + 1, $singleQuote2 - $singleQuote1 - 1);
} else if($parenthesis1 > 0 && $parenthesis2 > $parenthesis1) {
// likely a function call, make sure open parenthesis is preceded by PHP name format
if(preg_match('/[_a-zA-Z][_a-zA-Z0-9]+\(/', $fileMatch)) $isValid = 'func';
} else {
// likely NOT a valid file match, as it doesn't have any of the expected characters
$isValid = false;
}
if($testFile) {
if(strrpos($testFile, '.')) {
// test contains a filename that needs extension validated
$parts = explode('.', $testFile);
$testExt = array_pop($parts);
if($testExt && in_array(strtolower($testExt), $this->extensions)) $isValid = 'file';
} else if($funcMatch == 'wireRenderFile' || $funcMatch == 'wireIncludeFile') {
// these methods don't require a file extension
$isValid = 'file';
}
}
return $isValid;
}
/**
* Compile global class/interface/function references to namespaced versions
*
* @param string $data
* @return bool Whether or not namespace changes were compiled
*
*/
protected function compileNamespace(&$data) {
/*
$pos = strpos($data, 'namespace');
if($pos !== false) {
if(preg_match('/(^.*)\s+namespace\s+[_a-zA-Z0-9\\\\]+\s*;/m', $data, $matches)) {
if(strpos($matches[1], '//') === false && strpos($matches[1], '/*') === false) {
// namespace already present, no need for namespace compilation
return false;
}
}
}
*/
$classes = get_declared_classes();
$classes = array_merge($classes, get_declared_interfaces());
// also add in all core classes, in case the have not yet been autoloaded
static $files = null;
if(is_null($files)) {
$files = array();
foreach(new \DirectoryIterator($this->wire()->config->paths->core) as $file) {
if($file->isDot() || $file->isDir()) continue;
$basename = $file->getBasename('.php');
if(strtoupper($basename[0]) == $basename[0]) {
$name = __NAMESPACE__ ? __NAMESPACE__ . "\\$basename" : $basename;
if(!in_array($name, $classes)) $files[] = $name;
}
}
}
// also add in all modules
foreach($this->wire()->modules as $module) {
$name = __NAMESPACE__ ? $module->className(true) : $module->className();
if(!in_array($name, $classes)) $classes[] = $name;
}
$classes = array_merge($classes, $files);
if(!__NAMESPACE__) $classes = array_merge($classes, array_keys($this->wire()->modules->getInstallable()));
$rawPHP = $this->rawPHP;
$rawDequotedPHP = $this->rawDequotedPHP;
// update classes and interfaces
foreach($classes as $class) {
if(__NAMESPACE__ && strpos($class, __NAMESPACE__ . '\\') !== 0) continue; // limit only to ProcessWire classes/interfaces
if(strpos($class, '\\') !== false) {
list($ns, $class) = explode('\\', $class, 2); // reduce to just class without namespace
} else {
$ns = '';
}
if($ns) {}
if(stripos($rawDequotedPHP, $class) === false) continue; // quick exit if class name not referenced in data
$patterns = array(
// 1=open 2=close
// all patterns match within 1 line only
"new" => '(new\s+)' . $class . '\s*(\(|;|\))', // 'new Page(' or 'new Page;' or 'new Page)'
"function" => '(function\s+[_a-zA-Z0-9]+\s*\([^\\\\)]*?)\b' . $class . '(\s+\$[_a-zA-Z0-9]+)', // 'function(Page $page' or 'function($a, Page $page'
"::" => '(^|[^_\\\\a-zA-Z0-9"\'])' . $class . '(::)', // constant ' Page::foo' or '(Page::foo' or '=Page::foo' or bitwise open
"extends" => '(\sextends\s+)' . $class . '(\s|\{|$)', // 'extends Page'
"implements" => '(\simplements[^{]*?[\s,]+)' . $class . '([^_a-zA-Z0-9]|$)', // 'implements Module' or 'implements Foo, Module'
"instanceof" => '(\sinstanceof\s+)' . $class . '([^_a-zA-Z0-9]|$)', // 'instanceof Page'
"$class " => '(\(\s*|,\s*)' . $class . '(\s+\$)', // type hinted '(Page $something' or '($foo, Page $something'
);
foreach($patterns as $check => $regex) {
if(stripos($rawDequotedPHP, $check) === false) continue;
if(!preg_match_all('/' . $regex . '/im', $rawDequotedPHP, $matches)) continue;
foreach($matches[0] as $key => $fullMatch) {
$open = $matches[1][$key];
$close = $matches[2][$key];
if(substr($open, -1) == '\\') continue; // if last character in open is '\' then skip the replacement
$className = __NAMESPACE__ ? '\\' . __NAMESPACE__ . '\\' . $class : '\\' . $class;
$repl = $open . $className . $close;
$data = str_replace($fullMatch, $repl, $data);
$rawPHP = str_replace($fullMatch, $repl, $rawPHP);
$rawDequotedPHP = str_replace($fullMatch, $repl, $rawDequotedPHP);
}
}
}
// update PW procedural function calls
$functions = get_defined_functions();
$hasFunctionExists = strpos($rawDequotedPHP, 'function_exists') !== false;
foreach($functions['user'] as $function) {
if(__NAMESPACE__) {
if(stripos($function, __NAMESPACE__ . '\\') !== 0) continue; // limit only to ProcessWire functions
list($ns, $function) = explode('\\', $function, 2); // reduce to just function name
$functionName = '\\' . __NAMESPACE__ . '\\' . $function;
} else {
if(stripos($function, '\\') !== 0) continue;
$functionName = '\\' . $function;
$ns = '';
}
if($ns) {}
if(stripos($rawDequotedPHP, $function) === false) continue; // if function name not mentioned in data, quick exit
$n = 0;
while(preg_match_all('/^(.*?[()!;,@\[=\s.])' . $function . '\s*\(/im', $rawPHP, $matches)) {
foreach($matches[0] as $key => $fullMatch) {
$open = $matches[1][$key];
if(strpos($open, 'function') !== false) continue; // skip function defined with same name
$repl = $open . $functionName . '(';
$data = str_replace($fullMatch, $repl, $data);
$rawPHP = str_replace($fullMatch, $repl, $rawPHP);
}
if(++$n > 5) break;
}
if($hasFunctionExists) {
$find = 'function_exists\s*\(\s*["\']' . $function . '["\']\s*\)';
$repl = "function_exists('$functionName')";
$data = preg_replace("/$find/i", $repl, $data);
}
}
// update other function calls
$ns = __NAMESPACE__ ? "\\ProcessWire" : "";
if(strpos($rawDequotedPHP, 'class_parents(') !== false) {
$data = preg_replace('/\bclass_parents\(/', $ns . '\\wireClassParents(', $data);
}
if(strpos($rawDequotedPHP, 'class_implements(') !== false) {
$data = preg_replace('/\bclass_implements\(/', $ns . '\\wireClassImplements(', $data);
}
return true;
}
/**
* Recursively copy all files from $source to $target, but only if $source file is $newer
*
* @param string $source
* @param string $target
* @param bool $recursive
* @return int Number of files copied
*
*/
protected function copyAllNewerFiles($source, $target, $recursive = true) {
$source = rtrim($source, '/') . '/';
$target = rtrim($target, '/') . '/';
// don't perform full copies of some directories
// @todo convert this to use the user definable exclusions list
$config = $this->wire()->config;
if($source === $config->paths->site) return 0;
if($source === $config->paths->siteModules) return 0;
if($source === $config->paths->templates) return 0;
if(!is_dir($target)) $this->wire()->files->mkdir($target, true);
$dir = new \DirectoryIterator($source);
$numCopied = 0;
foreach($dir as $file) {
if($file->isDot()) continue;
$sourceFile = $file->getPathname();
$targetFile = $target . $file->getBasename();
if($file->isDir()) {
if($recursive) {
$numCopied += $this->copyAllNewerFiles($sourceFile, $targetFile, $recursive);
}
continue;
}
$ext = strtolower($file->getExtension());
if(!in_array($ext, $this->extensions)) continue;
if(is_file($targetFile)) {
if(filemtime($targetFile) >= filemtime($sourceFile)) {
$numCopied++;
continue;
}
}
copy($sourceFile, $targetFile);
$this->chmod($targetFile);
$this->touch($targetFile, filemtime($sourceFile));
$numCopied++;
}
if(!$numCopied) {
$this->wire()->files->rmdir($target, true);
}
return $numCopied;
}
/**
* Get a count of how many files are in the cache
*
* @param bool $all Specify true to get a count for all file compiler caches
* @param string $targetPath for internal recursion use, public calls should omit this
* @return int
*
*/
public function getNumCacheFiles($all = false, $targetPath = null) {
if(!is_null($targetPath)) {
// use it
} else if($all) {
$targetPath = $this->cachePath;
} else {
$this->init();
$targetPath = $this->targetPath;
}
if(!is_dir($targetPath)) return 0;
$numFiles = 0;
foreach(new \DirectoryIterator($targetPath) as $file) {
if($file->isDot()) continue;
if($file->isDir()) {
$numFiles += $this->getNumCacheFiles($all, $file->getPathname());
} else {
$numFiles++;
}
}
return $numFiles;
}
/**
* Clear all file compiler caches
*
* @param bool $all Specify true to clear for all FileCompiler caches
* @return bool
*
*/
public function clearCache($all = false) {
if($all) {
$targetPath = $this->cachePath;
$this->wire()->cache->deleteFor($this);
} else {
$this->init();
$targetPath = $this->targetPath;
}
if(!is_dir($targetPath)) return true;
return $this->wire()->files->rmdir($targetPath, true);
}
/**
* Run maintenance on the FileCompiler cache
*
* This should be called at the end of each request.
*
* @param int $interval Number of seconds between maintenance runs (default=86400)
* @return bool Whether or not it was necessary to run maintenance
*
*/
public function maintenance($interval = 86400) {
$this->init();
$this->initTargetPath();
$lastRunFile = $this->targetPath . 'maint.last';
if(file_exists($lastRunFile) && filemtime($lastRunFile) > time() - $interval) {
// maintenance already run today
return false;
}
$this->touch($lastRunFile);
$this->chmod($lastRunFile);
clearstatcache();
return $this->_maintenance($this->sourcePath, $this->targetPath);
}
/**
* Implementation for maintenance on a given path
*
* Logs maintenance actions to logs/file-compiler.txt
*
* @param $sourcePath
* @param $targetPath
* @return bool
*
*/
protected function _maintenance($sourcePath, $targetPath) {
$config = $this->wire()->config;
$files = $this->wire()->files;
$sourcePath = rtrim($sourcePath, '/') . '/';
$targetPath = rtrim($targetPath, '/') . '/';
$sourceURL = str_replace($config->paths->root, '/', $sourcePath);
$targetURL = str_replace($config->paths->root, '/', $targetPath);
$useLog = $this->globalOptions['logNotices'];
//$this->log("Running maintenance for $targetURL (source: $sourceURL)");
if(!is_dir($targetPath)) return false;
$dir = new \DirectoryIterator($targetPath);
foreach($dir as $file) {
if($file->isDot()) continue;
$basename = $file->getBasename();
if($basename == 'maint.last') continue;
$targetFile = $file->getPathname();
$sourceFile = $sourcePath . $basename;
if($file->isDir()) {
if(!is_dir($sourceFile)) {
$files->rmdir($targetFile, true);
if($useLog) $this->log("Maintenance/Remove directory: $targetURL$basename");
} else {
$this->_maintenance($sourceFile, $targetFile);
}
continue;
}
if(!file_exists($sourceFile)) {
// source file has been deleted
$files->unlink($targetFile, true);
if($useLog) $this->log("Maintenance/Remove target file: $targetURL$basename");
} else if(filemtime($sourceFile) > filemtime($targetFile)) {
// source file has changed
copy($sourceFile, $targetFile);
$this->chmod($targetFile);
$this->touch($targetFile, filemtime($sourceFile));
if($useLog) $this->log("Maintenance/Copy new version of source file to target file: $sourceURL$basename => $targetURL$basename");
}
}
return true;
}
/**
* Given an array of $options convert to an PHP-code array() string
*
* @param array $options
* @return string
*
*/
protected function optionsToString(array $options) {
$str = "array(";
foreach($options as $key => $value) {
if(is_bool($value)) {
$value = $value ? "true" : "false";
} else if(is_string($value)) {
$value = '"' . str_replace('"', '\\"', $value) . '"';
} else if(is_array($value)) {
if(count($value)) {
$value = "array('" . implode("',", $value) . "')";
} else {
$value = "array()";
}
}
$str .= "'$key'=>$value,";
}
$str = rtrim($str, ",") . ")";
return $str;
}
/**
* Exclude a file or path from compilation
*
* @param string $pathname
*
*/
public function addExclusion($pathname) {
$this->exclusions[] = $pathname;
}
/**
* Same as PHP touch() but with fallbacks for cases where touch() does not work
*
* @param string $filename
* @param null|int $time
* @return bool
*
*/
protected function touch($filename, $time = null) {
if($time === null) {
$result = @touch($filename);
} else {
$result = @touch($filename, $time);
// try again, but without time
if(!$result) $result = @touch($filename);
}
if(!$result) {
// lastly try alternative method which should have same affect as touch without $time
$fp = fopen($filename, 'a');
$result = $fp !== false ? fclose($fp) : false;
}
return $result;
}
}