*/ use dokuwiki\Extension\Plugin; use dokuwiki\plugin\sqlite\SQLiteDB; use dokuwiki\ErrorHandler; /** * Class helper_plugin_doxycode_buildmanager * * This class manages the build of code snippets with doxygen for cross referencing. * * Job: A build job is a single code snippet. * JobID: md5 of doxygen config + code. * Task: A build task has one or multiple build jobs. * TaskID: md5 of doxygen config. * * The build is executed in build tasks that are either directly executed or scheduled with * the help of the sqlite plugin. If the sqlite plugin is not available, the scheduling fails. * Since the task is then not scheduled and no cache file is available, the snippet syntax * should then try to build the code snippet again. * * Depending on the used tag files for doxygen, each build can both take long and be ressource * hungry. It therefore is only allowed to have one doxygen instance running at all times. This * is enforced with a lock that indicates if doxygen is running or not. In case of immediate build * tasks through tryBuildNow() the buildmanager will then try to schedule the build task. * * If a build with the same TaskID is already running, a new TaskID will be randomly created. * This way we ensure that we don't mess with an already running task. * * @author Lukas Probsthain */ class helper_plugin_doxycode_buildmanager extends Plugin { public const STATE_NON_EXISTENT = 1; public const STATE_RUNNING = 2; public const STATE_SCHEDULED = 3; public const STATE_FINISHED = 4; public const STATE_ERROR = 5; /** * @var string Filename of the lock file for only allowing one doxygen process. */ protected const LOCKFILENAME = '_plugin_doxycode.lock'; protected $db = null; /** * @var array Allowed configuration strings that are relevant for doxygen. */ private $conf_doxygen_keys = array( 'tagfiles', 'doxygen_conf', 'language' ); /** * @var String[] Configuration strings that are only relevant for the snippet syntax. */ private $conf_doxycode_keys = array( 'render_task' ); public function __construct() { // check if sqlite is available if (!plugin_isdisabled('sqlite')) { if ($this->db === null) { try { $this->db = new SQLiteDB('doxycode', DOKU_PLUGIN . 'doxycode/db/'); } catch (\Exception $exception) { if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception); ErrorHandler::logException($exception); msg('Couldn\'t load sqlite.', -1); } } } } /** * Add a build job to the task runner builder and create or update a build task if necessary. * * This function adds a new build job to the Jobs table in sqlite. * Each build job corresponds to a build task. If no build task exists for the build job it will create a new one * and insert it to the Tasks table in sqlite. * * If the build task for this build job is existing it will try to * change its state to 'STATE_SCHEDULED' to run it again. * If the build task is already running, we don't want to interfere the doxygen build process. In that case * we create a new build task for this build job with a random taskID. * * @param String $jobID Identifier for this build job * @param Array &$config Arguments from the snippet syntax containing the configuration for the snippet * @param String $content Code snippet content * @return Bool If adding the build job was successful */ public function addBuildJob($jobID, &$config, $content) { if ($this->db === null) { return false; } // TODO: is a race condition possible where we add a job to a // task and during that operation the task runner start executing the task? // check if the Task is already running $row = $this->db->queryRecord('SELECT * FROM Tasks WHERE TaskID = ?', [$config['taskID']]); switch ($row['State']) { case self::STATE_ERROR: // fall through case self::STATE_FINISHED: { // this means that the build directory probably was already deleted // we can just recreate the directory or put our job into the existing build directory $id = $this->db->exec( 'UPDATE Tasks SET Timestamp = CURRENT_TIMESTAMP, State = ? WHERE TaskID = ?', [self::STATE_SCHEDULED, $config['taskID']] ); break; } case self::STATE_SCHEDULED: { // just update the timestamp so we don't accidentally delete this task $id = $this->db->exec( 'UPDATE Tasks SET Timestamp = CURRENT_TIMESTAMP WHERE TaskID = ?', [$config['taskID']] ); break; } case self::STATE_RUNNING: { // Generate an new TaskID $config['taskID'] = md5(microtime(true) . mt_rand()); } case null; case '': { // we need to create a new task! $id = $this->db->exec( 'INSERT INTO Tasks (TaskID, State, Timestamp, Configuration) VALUES (?, ?, CURRENT_TIMESTAMP, ?)', [ $config['taskID'], self::STATE_SCHEDULED, json_encode( $this->filterDoxygenAttributes($config, false), true ) ] ); break; } } // create the job file with the code snippet content $tmp_dir = $this->createJobFile($jobID, $config, $content); if (strlen($tmp_dir) == 0) { return false; } // create the job in sqlite $data = [ 'JobID' => $jobID, 'TaskID' => $config['taskID'], 'Configuration' => json_encode($this->filterDoxygenAttributes($config, true), true) ]; $new = $this->db->saveRecord('Jobs', $data); return true; } /** * Try to immediately build a code snippet. * * If a lock for the doxygen process is present doxygen is already running. * In that case we try to add the build job to the build queue (if sqlite is available). * * @param String $jobID Identifier for this build job * @param Array &$config Arguments from the snippet syntax containing the configuration for the snippet * @param String $content Code snippet content * @param Array $tag_conf Tag file configuration used for passing the tag files to doxygen * @return Bool If build or adding it to the build queue as a build job was successful */ public function tryBuildNow($jobID, &$config, $content, $tag_conf) { global $conf; // first try to detect if a doxygen instance is already running if (!$this->lock()) { // we cannot build now because only one doxygen instance is allowed at a time! // this will return false if task runner is not available // otherwise it will create a task and a job return $this->addBuildJob($jobID, $conf, $content); $config['render_task'] = true; } // no doxygen instance is running - we can immediately build the file! // TODO: should we also create entries for Task and Job in sqlite if we directly build the snippet? // create the directory where rendering with doxygen takes place $tmp_dir = $this->createJobFile($jobID, $config, $content); if (strlen($tmp_dir) == 0) { $this->unlock(); return false; } // run doxygen on our file with XML output $buildsuccess = $this->runDoxygen($tmp_dir, $tag_conf); // delete tmp_dir if (!$conf['allowdebug']) { $this->deleteTaskDir($tmp_dir); } $this->unlock(); return $buildsuccess; } /** * Get the state of a build task from the Tasks table in sqlite. * * If no entry for this task could be found in sqlite we return STATE_NON_EXISTENT. * * @param String $id TaskID of the build task * @return Num Task State */ public function getTaskState($id) { if ($this->db === null) { // TODO: better return value? return self::STATE_NON_EXISTENT; } $row = $this->db->queryRecord('SELECT * FROM Tasks WHERE TaskID = ?', $id); if ($row !== null) { return $row['State']; } else { return self::STATE_NON_EXISTENT; } } /** * Get the state of a build job. * * Here we first lookup the corresponding build task for the build job and then lookup * the task state with getTaskState. * * @param String $jobID JobID for this build job * @return Num Job State */ public function getJobState($jobID) { if ($this->db === null) { // TODO: better return value? return self::STATE_NON_EXISTENT; } // get the TaskID from sqlite $row = $this->db->queryRecord('SELECT * FROM Jobs WHERE JobID = ?', $jobID); // check the task state and return as job state if ($row !== null) { // TODO: can we directly retreive the Task from our reference in sqlite? return $this->getTaskState($row['TaskID']); } else { return self::STATE_NON_EXISTENT; } } /** * Return the doxygen relevant build task configuration of a build task. * * This is useful in a context where the configuration can not be obtained from the snippet syntax. * * An example for this is the 'plugin_doxycode_get_snippet_html' ajax call where the doxygen output XML is parsed. * There we need the configuration for matching the used tag files. * * @param String $taskID TaskID for this build task * @return Array Task configuration including the used tag files */ public function getTaskConf($taskID) { if ($this->db === null) { // TODO: better return value? return []; } $row = $this->db->queryRecord('SELECT Configuration FROM Tasks WHERE TaskID = ?', $taskID); if ($row !== null) { return json_decode($row['Configuration'], true); } else { return []; } } /** * Return the doxygen relevant build task configuration configuration of a build job. * * This is useful in a context where the configuration can not be obtained from the snippet syntax. * * We first obtain the corresponding TaskID from the Jobs table in sqlite. * We then call getTaskConf to get the task configuration. * * @param String $jobID JobID for this Job * @return Array Task configuration including the used tag files */ public function getJobTaskConf($jobID) { if ($this->db === null) { // TODO: better return value? return []; } // get the TaskID from sqlite $row = $this->db->queryRecord('SELECT * FROM Jobs WHERE JobID = ?', $jobID); // get the Configuration from the Task if ($row !== null) { return $this->getTaskConf($row['TaskID']); } else { return []; } } /** * Get the HTML relevant configuration of a build job. * * This is useful in a context where the configuration can not be obtained from the snippet syntax. * * @param String $jobID JobID for this Job * @return Array Task configuration including linenumbers, filename, etc. */ public function getJobConf($jobID) { if ($this->db === null) { // TODO: better return value? return []; } // get the TaskID from sqlite $row = $this->db->queryRecord('SELECT Configuration FROM Jobs WHERE JobID = ?', $jobID); if ($row !== null) { return json_decode($row['Configuration'], true); } else { return []; } } /** * Check if a lock for the doxygen process is present. * * @return Bool Is a lock present? */ private function isRunning() { global $conf; $lock = $conf['lockdir'] . self::LOCKFILENAME; return file_exists($lock); } /** * Create a lock for the doxygen process. * * This is used for ensuring that only one doxygen process can be present at all times. * * If a lock is present we check if the lock file is older than the maximum allowed execution time * of the doxygen task runner. We then assume that the lock is stale and remove it. * * If a lock is present and not stale locking fails. * * @return Bool Was locking successful? */ private function lock() { global $conf; $lock = $conf['lockdir'] . self::LOCKFILENAME; if (file_exists($lock)) { if (time() - @filemtime($lock) > $this->getConf('runner_max_execution_time')) { // looks like a stale lock - remove it unlink($lock); } else { return false; } } // try creating the lock file io_savefile($lock, ""); return true; } /** * Remove the doxygen process lock file. * * @return Bool Was removing the lock successful? */ private function unlock() { global $conf; $lock = $conf['lockdir'] . self::LOCKFILENAME; return unlink($lock); } /** * Execute a doxygen task runner build task. * * We obtain the build task from the Tasks table in sqlite. * The build task row includes used tag files from the snippet syntax. * * We then load the tag file configuration for those tag files and try to execute the build. * * After the doxygen process exited we update the build task state in sqlite. * * @param String $taskID TaskID for this build task * @return Bool Was building successful? */ public function runTask($taskID) { global $conf; if (!$this->lock()) { // a task is already running return false; } // get the config from sqlite! $row = $this->db->queryRecord('SELECT * FROM Tasks WHERE TaskID = ?', $taskID); // we only want to run if we have a scheduled job! if ($row === null || $row['State'] != self::STATE_SCHEDULED) { $this->unlock(); return false; } $config = json_decode($row['Configuration'], true); /** @var helper_plugin_doxycode_tagmanager $tagmanager */ $tagmanager = plugin_load('helper', 'doxycode_tagmanager'); // load the tag_config from the tag file list if (!is_array($config['tagfiles'])) { $config['tagfiles'] = [$config['tagfiles']]; } $tag_config = $tagmanager->getFilteredTagConfig($config['tagfiles']); // update the maximum execution time according to configuration // TODO: maybe check if this configuration is present? set_time_limit($this->getConf('runner_max_execution_time')); // this just returns the build dir if already existent $tmpDir = $this->createTaskDir($taskID); // update the task state // we do not update the timestamp of the task here $this->db->exec( 'UPDATE Tasks SET State = ? WHERE TaskID = ?', [self::STATE_RUNNING, $taskID] ); // execute doxygen and move cache files into position $success = $this->runDoxygen($tmpDir, $tag_config); // update the task state if ($success) { $this->db->exec( 'UPDATE Tasks SET State = ? WHERE TaskID = ?', [self::STATE_FINISHED, $taskID] ); } else { $this->db->exec( 'UPDATE Tasks SET State = ? WHERE TaskID = ?', [self::STATE_ERROR, $taskID] ); } // delete tmp_dir if (!$conf['allowdebug']) { $this->deleteTaskDir($tmpDir); } $this->unlock(); return true; } /** * Execute doxygen in a shell. * * The doxygen configuration is passed to doxygen via a pipe and the TAGFILES parameter * is overridden with the tag file configuration passed to this function. * * In the xml output directory all matching XML output files are extracted and placed * where the other plugin components expect the XML cache file. This is done by extracting the XML * cache ID from the doxygen XML output filename (the source files in the doxygen directory where named * after the cache ID). * * @param String $build_dir Directory where doxygen should be executed. * @param Array $tag_conf Tag file configuration * @return Bool Was the execution successful? */ private function runDoxygen($build_dir, $tag_conf = null) { if (!is_dir($build_dir)) { // the directory does not exist return false; } $doxygenExecutable = $this->getConf('doxygen_executable'); // check if doxygen executable exists! if (!file_exists($doxygenExecutable)) { return false; } // Path to your Doxygen configuration file // TODO: use default doxygen config or allow admin to upload // a doxygen configuration that is not overwritten by plugin updates $doxygenConfig = DOKU_PLUGIN . $this->getPluginName() . '/doxygen.conf'; /** @var helper_plugin_doxycode_tagmanager $tagmanager */ $tagmanager = plugin_load('helper', 'doxycode_tagmanager'); // TAGFILES variable value you want to set $tagfiles = ''; $index = 0; foreach ($tag_conf as $key => $conf) { if ($index > 0) { $tagfiles .= ' '; } $tagfiles .= '"' . $tagmanager->getTagFileDir() . $key . '.xml=' . $conf['docu_url'] . '"'; $index++; } // TODO: allow more configuration settings for doxygen execution through the doxygen_conf parameter // Running the Doxygen command with overridden TAGFILES exec( "cd $build_dir && ( cat $doxygenConfig ; echo 'TAGFILES=$tagfiles' ) | $doxygenExecutable -", $output, $returnVar ); // now extract the XML files from the build directory // Find all XML files in the directory $files = glob($build_dir . '/xml/*_8*.xml'); foreach ($files as $file) { // Get the file name without extension $filename = pathinfo($file, PATHINFO_FILENAME); $cache_name = pathinfo($filename, PATHINFO_FILENAME); $cache_name = explode('_8', $cache_name); // move XML to cache file position $cache_name = getCacheName($cache_name[0], ".xml"); copy($file, $cache_name); } return $returnVar === 0; } /** * The function _getXMLOutputName takes a filename * in the Doxygen workspace and converts it to the output XML filename. * * @todo: can probably be deleted! * * @param string Name of a source file in the doxygen workspace. * * @return string Name of the XML file output by Doxygen for the given source file. */ private function getXMLOutputName($filename) { return str_replace(".", "_8", $filename) . '.xml'; } /** * The function creates a temporary directory for building the Doxygen documentation. * * @return string Directory where Doxygen can be executed. */ private function createTaskDir($taskID) { global $conf; $tempDir = $conf['tmpdir'] . '/doxycode/'; // check if we already have a doxycode directory if (!is_dir($tempDir)) { mkdir($tempDir); } $tempDir .= $taskID; if (!is_dir($tempDir)) { mkdir($tempDir); } return $tempDir; } /** * Delete the temporary build task directory. * * @param String $dirPath Build directory where doxygen is executed. */ private function deleteTaskDir($dirPath) { if (! is_dir($dirPath)) { throw new InvalidArgumentException("$dirPath must be a directory"); } io_rmdir($dirPath, true); } /** * Create the source file in the build directory of the build task. * * The $config variable includes the corresponding TaskID. * First we try to create the temporary build directory. * * We than place the content of the code snippet in a source file in the build directory. * * The extension of the source file is the 'language' variable in $config. * The filename of the source file is the cache ID (=JobID) of the XML cache file. * This can later be used to place the doxygen output XML at the appropriate XML cache file name. * * @param String $jobID JobID for this build job * @param Array &$config Configuration from the snippet syntax * @param String $content Content from the code snippet */ private function createJobFile($jobID, &$config, $content) { // if we do not already have a job directory, create it $tmpDir = $this->createTaskDir($config['taskID']); if (!is_dir($tmpDir)) { return ''; } // we expect a cache filename (md5) - the xml output from the build job will have this filename // thereby the doxygen builder can correctly identify where the XML output file should be placed // getCacheName() can later be used to get the correct place for the cache file $render_file_name = $jobID . '.' . $config['language']; // Attempt to write the content to the file $result = file_put_contents($tmpDir . '/' . $render_file_name, $content); // TODO: maybe throw error here and try catch where used... return $tmpDir; } /** * Get a list of scheduled build tasks from the Tasks table in sqlite. * * @param Num $amount Amount of build tasks to return. * @return Array Build tasks */ public function getBuildTasks($amount = PHP_INT_MAX) { // get build tasks from SQLite // the order should be the same for all functions // first one is the one currently built or the next one do build if ($this->db === null) { return false; } // get the oldest task first $rows = $this->db->queryAll( 'SELECT TaskID FROM Tasks WHERE TaskID IS NOT NULL AND State = ? ORDER BY Timestamp ASC LIMIT ?', [self::STATE_SCHEDULED, $amount] ); return $rows; } /** * Filter the doxygen relevant attributes from a configuration array. * * The doxygen relevant attributes are parameters that are passed to doxygen when building. * Examples: tag file configuration * * The configuration also includes attributes that only influence task scheduling (e.g. 'render_task' which forces * task runner build from the syntax). Here we filter those values out. * * This function is especially useful for generating the cache file IDs. * * @param Array $config Configuration from the snippet syntax. * @param bool $exclude Return only doxygen relevant configuration or everying else * @return Array filtered configuration */ public function filterDoxygenAttributes($config, $exclude = false) { $filtered_config = []; // filter tag_config by tag_names if (!$exclude) { $filtered_config = array_intersect_key($config, array_flip($this->conf_doxygen_keys)); } else { $filtered_config = array_diff_key($config, array_flip($this->conf_doxygen_keys)); } // filter out keys that are only relevant for the snippet syntax $filtered_config = array_diff_key($filtered_config, array_flip($this->conf_doxycode_keys)); $filtered_config = is_array($filtered_config) ? $filtered_config : [$filtered_config]; return $filtered_config; } }