1<?php 2 3/** 4 * DokuWiki Plugin doxycode (Buildmanager Helper Component) 5 * 6 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 7 * @author Lukas Probsthain <lukas.probsthain@gmail.com> 8 */ 9 10use dokuwiki\Extension\Plugin; 11use dokuwiki\plugin\sqlite\SQLiteDB; 12use dokuwiki\ErrorHandler; 13 14/** 15 * Class helper_plugin_doxycode_buildmanager 16 * 17 * This class manages the build of code snippets with doxygen for cross referencing. 18 * 19 * Job: A build job is a single code snippet. 20 * JobID: md5 of doxygen config + code. 21 * Task: A build task has one or multiple build jobs. 22 * TaskID: md5 of doxygen config. 23 * 24 * The build is executed in build tasks that are either directly executed or scheduled with 25 * the help of the sqlite plugin. If the sqlite plugin is not available, the scheduling fails. 26 * Since the task is then not scheduled and no cache file is available, the snippet syntax 27 * should then try to build the code snippet again. 28 * 29 * Depending on the used tag files for doxygen, each build can both take long and be ressource 30 * hungry. It therefore is only allowed to have one doxygen instance running at all times. This 31 * is enforced with a lock that indicates if doxygen is running or not. In case of immediate build 32 * tasks through tryBuildNow() the buildmanager will then try to schedule the build task. 33 * 34 * If a build with the same TaskID is already running, a new TaskID will be randomly created. 35 * This way we ensure that we don't mess with an already running task. 36 * 37 * @author Lukas Probsthain <lukas.probsthain@gmail.com> 38 */ 39class helper_plugin_doxycode_buildmanager extends Plugin 40{ 41 public const STATE_NON_EXISTENT = 1; 42 public const STATE_RUNNING = 2; 43 public const STATE_SCHEDULED = 3; 44 public const STATE_FINISHED = 4; 45 public const STATE_ERROR = 5; 46 47 /** 48 * @var string Filename of the lock file for only allowing one doxygen process. 49 */ 50 protected const LOCKFILENAME = '_plugin_doxycode.lock'; 51 52 protected $db = null; 53 54 55 /** 56 * @var array Allowed configuration strings that are relevant for doxygen. 57 */ 58 private $conf_doxygen_keys = array( 59 'tagfiles', 60 'doxygen_conf', 61 'language' 62 ); 63 64 /** 65 * @var String[] Configuration strings that are only relevant for the snippet syntax. 66 */ 67 private $conf_doxycode_keys = array( 68 'render_task' 69 ); 70 71 public function __construct() 72 { 73 // check if sqlite is available 74 if (!plugin_isdisabled('sqlite')) { 75 if ($this->db === null) { 76 try { 77 $this->db = new SQLiteDB('doxycode', DOKU_PLUGIN . 'doxycode/db/'); 78 } catch (\Exception $exception) { 79 if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception); 80 ErrorHandler::logException($exception); 81 msg('Couldn\'t load sqlite.', -1); 82 } 83 } 84 } 85 } 86 87 /** 88 * Add a build job to the task runner builder and create or update a build task if necessary. 89 * 90 * This function adds a new build job to the Jobs table in sqlite. 91 * Each build job corresponds to a build task. If no build task exists for the build job it will create a new one 92 * and insert it to the Tasks table in sqlite. 93 * 94 * If the build task for this build job is existing it will try to 95 * change its state to 'STATE_SCHEDULED' to run it again. 96 * If the build task is already running, we don't want to interfere the doxygen build process. In that case 97 * we create a new build task for this build job with a random taskID. 98 * 99 * @param String $jobID Identifier for this build job 100 * @param Array &$config Arguments from the snippet syntax containing the configuration for the snippet 101 * @param String $content Code snippet content 102 * @return Bool If adding the build job was successful 103 */ 104 public function addBuildJob($jobID, &$config, $content) 105 { 106 if ($this->db === null) { 107 return false; 108 } 109 110 // TODO: is a race condition possible where we add a job to a 111 // task and during that operation the task runner start executing the task? 112 113 // check if the Task is already running 114 $row = $this->db->queryRecord('SELECT * FROM Tasks WHERE TaskID = ?', [$config['taskID']]); 115 116 switch ($row['State']) { 117 case self::STATE_ERROR: // fall through 118 case self::STATE_FINISHED: { 119 // this means that the build directory probably was already deleted 120 // we can just recreate the directory or put our job into the existing build directory 121 $id = $this->db->exec( 122 'UPDATE Tasks SET Timestamp = CURRENT_TIMESTAMP, State = ? WHERE TaskID = ?', 123 [self::STATE_SCHEDULED, $config['taskID']] 124 ); 125 break; 126 } 127 case self::STATE_SCHEDULED: { 128 // just update the timestamp so we don't accidentally delete this task 129 $id = $this->db->exec( 130 'UPDATE Tasks SET Timestamp = CURRENT_TIMESTAMP WHERE TaskID = ?', 131 [$config['taskID']] 132 ); 133 break; 134 } 135 case self::STATE_RUNNING: { 136 // Generate an new TaskID 137 $config['taskID'] = md5(microtime(true) . mt_rand()); 138 } 139 case null; 140 case '': { 141 // we need to create a new task! 142 $id = $this->db->exec( 143 'INSERT INTO Tasks (TaskID, State, Timestamp, Configuration) VALUES (?, ?, CURRENT_TIMESTAMP, ?)', 144 [ 145 $config['taskID'], 146 self::STATE_SCHEDULED, 147 json_encode( 148 $this->filterDoxygenAttributes($config, false), 149 true 150 ) 151 ] 152 ); 153 break; 154 } 155 } 156 157 // create the job file with the code snippet content 158 $tmp_dir = $this->createJobFile($jobID, $config, $content); 159 160 if (strlen($tmp_dir) == 0) { 161 return false; 162 } 163 164 // create the job in sqlite 165 166 $data = [ 167 'JobID' => $jobID, 168 'TaskID' => $config['taskID'], 169 'Configuration' => json_encode($this->filterDoxygenAttributes($config, true), true) 170 ]; 171 172 $new = $this->db->saveRecord('Jobs', $data); 173 174 return true; 175 } 176 177 /** 178 * Try to immediately build a code snippet. 179 * 180 * If a lock for the doxygen process is present doxygen is already running. 181 * In that case we try to add the build job to the build queue (if sqlite is available). 182 * 183 * @param String $jobID Identifier for this build job 184 * @param Array &$config Arguments from the snippet syntax containing the configuration for the snippet 185 * @param String $content Code snippet content 186 * @param Array $tag_conf Tag file configuration used for passing the tag files to doxygen 187 * @return Bool If build or adding it to the build queue as a build job was successful 188 */ 189 public function tryBuildNow($jobID, &$config, $content, $tag_conf) 190 { 191 global $conf; 192 193 // first try to detect if a doxygen instance is already running 194 if (!$this->lock()) { 195 // we cannot build now because only one doxygen instance is allowed at a time! 196 // this will return false if task runner is not available 197 // otherwise it will create a task and a job 198 return $this->addBuildJob($jobID, $conf, $content); 199 200 $config['render_task'] = true; 201 } 202 203 // no doxygen instance is running - we can immediately build the file! 204 205 // TODO: should we also create entries for Task and Job in sqlite if we directly build the snippet? 206 207 // create the directory where rendering with doxygen takes place 208 $tmp_dir = $this->createJobFile($jobID, $config, $content); 209 210 if (strlen($tmp_dir) == 0) { 211 $this->unlock(); 212 213 return false; 214 } 215 216 // run doxygen on our file with XML output 217 $buildsuccess = $this->runDoxygen($tmp_dir, $tag_conf); 218 219 // delete tmp_dir 220 if (!$conf['allowdebug']) { 221 $this->deleteTaskDir($tmp_dir); 222 } 223 224 225 $this->unlock(); 226 227 return $buildsuccess; 228 } 229 230 /** 231 * Get the state of a build task from the Tasks table in sqlite. 232 * 233 * If no entry for this task could be found in sqlite we return STATE_NON_EXISTENT. 234 * 235 * @param String $id TaskID of the build task 236 * @return Num Task State 237 */ 238 public function getTaskState($id) 239 { 240 if ($this->db === null) { 241 // TODO: better return value? 242 return self::STATE_NON_EXISTENT; 243 } 244 245 $row = $this->db->queryRecord('SELECT * FROM Tasks WHERE TaskID = ?', $id); 246 247 if ($row !== null) { 248 return $row['State']; 249 } else { 250 return self::STATE_NON_EXISTENT; 251 } 252 } 253 254 /** 255 * Get the state of a build job. 256 * 257 * Here we first lookup the corresponding build task for the build job and then lookup 258 * the task state with getTaskState. 259 * 260 * @param String $jobID JobID for this build job 261 * @return Num Job State 262 */ 263 public function getJobState($jobID) 264 { 265 if ($this->db === null) { 266 // TODO: better return value? 267 return self::STATE_NON_EXISTENT; 268 } 269 270 // get the TaskID from sqlite 271 $row = $this->db->queryRecord('SELECT * FROM Jobs WHERE JobID = ?', $jobID); 272 273 // check the task state and return as job state 274 if ($row !== null) { 275 // TODO: can we directly retreive the Task from our reference in sqlite? 276 return $this->getTaskState($row['TaskID']); 277 } else { 278 return self::STATE_NON_EXISTENT; 279 } 280 } 281 282 /** 283 * Return the doxygen relevant build task configuration of a build task. 284 * 285 * This is useful in a context where the configuration can not be obtained from the snippet syntax. 286 * 287 * An example for this is the 'plugin_doxycode_get_snippet_html' ajax call where the doxygen output XML is parsed. 288 * There we need the configuration for matching the used tag files. 289 * 290 * @param String $taskID TaskID for this build task 291 * @return Array Task configuration including the used tag files 292 */ 293 public function getTaskConf($taskID) 294 { 295 if ($this->db === null) { 296 // TODO: better return value? 297 return []; 298 } 299 300 $row = $this->db->queryRecord('SELECT Configuration FROM Tasks WHERE TaskID = ?', $taskID); 301 302 if ($row !== null) { 303 return json_decode($row['Configuration'], true); 304 } else { 305 return []; 306 } 307 } 308 309 /** 310 * Return the doxygen relevant build task configuration configuration of a build job. 311 * 312 * This is useful in a context where the configuration can not be obtained from the snippet syntax. 313 * 314 * We first obtain the corresponding TaskID from the Jobs table in sqlite. 315 * We then call getTaskConf to get the task configuration. 316 * 317 * @param String $jobID JobID for this Job 318 * @return Array Task configuration including the used tag files 319 */ 320 public function getJobTaskConf($jobID) 321 { 322 if ($this->db === null) { 323 // TODO: better return value? 324 return []; 325 } 326 327 // get the TaskID from sqlite 328 $row = $this->db->queryRecord('SELECT * FROM Jobs WHERE JobID = ?', $jobID); 329 330 // get the Configuration from the Task 331 if ($row !== null) { 332 return $this->getTaskConf($row['TaskID']); 333 } else { 334 return []; 335 } 336 } 337 338 /** 339 * Get the HTML relevant configuration of a build job. 340 * 341 * This is useful in a context where the configuration can not be obtained from the snippet syntax. 342 * 343 * @param String $jobID JobID for this Job 344 * @return Array Task configuration including linenumbers, filename, etc. 345 */ 346 public function getJobConf($jobID) 347 { 348 if ($this->db === null) { 349 // TODO: better return value? 350 return []; 351 } 352 353 // get the TaskID from sqlite 354 $row = $this->db->queryRecord('SELECT Configuration FROM Jobs WHERE JobID = ?', $jobID); 355 356 if ($row !== null) { 357 return json_decode($row['Configuration'], true); 358 } else { 359 return []; 360 } 361 } 362 363 /** 364 * Check if a lock for the doxygen process is present. 365 * 366 * @return Bool Is a lock present? 367 */ 368 private function isRunning() 369 { 370 global $conf; 371 372 $lock = $conf['lockdir'] . self::LOCKFILENAME; 373 374 return file_exists($lock); 375 } 376 377 /** 378 * Create a lock for the doxygen process. 379 * 380 * This is used for ensuring that only one doxygen process can be present at all times. 381 * 382 * If a lock is present we check if the lock file is older than the maximum allowed execution time 383 * of the doxygen task runner. We then assume that the lock is stale and remove it. 384 * 385 * If a lock is present and not stale locking fails. 386 * 387 * @return Bool Was locking successful? 388 */ 389 private function lock() 390 { 391 global $conf; 392 393 $lock = $conf['lockdir'] . self::LOCKFILENAME; 394 395 if (file_exists($lock)) { 396 if (time() - @filemtime($lock) > $this->getConf('runner_max_execution_time')) { 397 // looks like a stale lock - remove it 398 unlink($lock); 399 } else { 400 return false; 401 } 402 } 403 404 // try creating the lock file 405 io_savefile($lock, ""); 406 407 return true; 408 } 409 410 /** 411 * Remove the doxygen process lock file. 412 * 413 * @return Bool Was removing the lock successful? 414 */ 415 private function unlock() 416 { 417 global $conf; 418 $lock = $conf['lockdir'] . self::LOCKFILENAME; 419 return unlink($lock); 420 } 421 422 /** 423 * Execute a doxygen task runner build task. 424 * 425 * We obtain the build task from the Tasks table in sqlite. 426 * The build task row includes used tag files from the snippet syntax. 427 * 428 * We then load the tag file configuration for those tag files and try to execute the build. 429 * 430 * After the doxygen process exited we update the build task state in sqlite. 431 * 432 * @param String $taskID TaskID for this build task 433 * @return Bool Was building successful? 434 */ 435 public function runTask($taskID) 436 { 437 global $conf; 438 439 if (!$this->lock()) { 440 // a task is already running 441 return false; 442 } 443 444 // get the config from sqlite! 445 $row = $this->db->queryRecord('SELECT * FROM Tasks WHERE TaskID = ?', $taskID); 446 447 // we only want to run if we have a scheduled job! 448 if ($row === null || $row['State'] != self::STATE_SCHEDULED) { 449 $this->unlock(); 450 return false; 451 } 452 453 $config = json_decode($row['Configuration'], true); 454 455 456 /** @var helper_plugin_doxycode_tagmanager $tagmanager */ 457 $tagmanager = plugin_load('helper', 'doxycode_tagmanager'); 458 // load the tag_config from the tag file list 459 if (!is_array($config['tagfiles'])) { 460 $config['tagfiles'] = [$config['tagfiles']]; 461 } 462 $tag_config = $tagmanager->getFilteredTagConfig($config['tagfiles']); 463 464 // update the maximum execution time according to configuration 465 // TODO: maybe check if this configuration is present? 466 set_time_limit($this->getConf('runner_max_execution_time')); 467 468 // this just returns the build dir if already existent 469 $tmpDir = $this->createTaskDir($taskID); 470 471 // update the task state 472 // we do not update the timestamp of the task here 473 $this->db->exec( 474 'UPDATE Tasks SET State = ? WHERE TaskID = ?', 475 [self::STATE_RUNNING, $taskID] 476 ); 477 478 // execute doxygen and move cache files into position 479 $success = $this->runDoxygen($tmpDir, $tag_config); 480 481 // update the task state 482 if ($success) { 483 $this->db->exec( 484 'UPDATE Tasks SET State = ? WHERE TaskID = ?', 485 [self::STATE_FINISHED, $taskID] 486 ); 487 } else { 488 $this->db->exec( 489 'UPDATE Tasks SET State = ? WHERE TaskID = ?', 490 [self::STATE_ERROR, $taskID] 491 ); 492 } 493 494 495 // delete tmp_dir 496 if (!$conf['allowdebug']) { 497 $this->deleteTaskDir($tmpDir); 498 } 499 500 $this->unlock(); 501 502 return true; 503 } 504 505 /** 506 * Execute doxygen in a shell. 507 * 508 * The doxygen configuration is passed to doxygen via a pipe and the TAGFILES parameter 509 * is overridden with the tag file configuration passed to this function. 510 * 511 * In the xml output directory all matching XML output files are extracted and placed 512 * where the other plugin components expect the XML cache file. This is done by extracting the XML 513 * cache ID from the doxygen XML output filename (the source files in the doxygen directory where named 514 * after the cache ID). 515 * 516 * @param String $build_dir Directory where doxygen should be executed. 517 * @param Array $tag_conf Tag file configuration 518 * @return Bool Was the execution successful? 519 */ 520 private function runDoxygen($build_dir, $tag_conf = null) 521 { 522 if (!is_dir($build_dir)) { 523 // the directory does not exist 524 return false; 525 } 526 527 $doxygenExecutable = $this->getConf('doxygen_executable'); 528 529 // check if doxygen executable exists! 530 if (!file_exists($doxygenExecutable)) { 531 return false; 532 } 533 534 // Path to your Doxygen configuration file 535 // TODO: use default doxygen config or allow admin to upload 536 // a doxygen configuration that is not overwritten by plugin updates 537 $doxygenConfig = DOKU_PLUGIN . $this->getPluginName() . '/doxygen.conf'; 538 539 540 /** @var helper_plugin_doxycode_tagmanager $tagmanager */ 541 $tagmanager = plugin_load('helper', 'doxycode_tagmanager'); 542 543 // TAGFILES variable value you want to set 544 $tagfiles = ''; 545 $index = 0; 546 foreach ($tag_conf as $key => $conf) { 547 if ($index > 0) { 548 $tagfiles .= ' '; 549 } 550 551 $tagfiles .= '"' . $tagmanager->getTagFileDir() . $key . '.xml=' . $conf['docu_url'] . '"'; 552 $index++; 553 } 554 555 // TODO: allow more configuration settings for doxygen execution through the doxygen_conf parameter 556 557 558 // Running the Doxygen command with overridden TAGFILES 559 exec( 560 "cd $build_dir && ( cat $doxygenConfig ; echo 'TAGFILES=$tagfiles' ) | $doxygenExecutable -", 561 $output, 562 $returnVar 563 ); 564 565 // now extract the XML files from the build directory 566 // Find all XML files in the directory 567 $files = glob($build_dir . '/xml/*_8*.xml'); 568 569 foreach ($files as $file) { 570 // Get the file name without extension 571 $filename = pathinfo($file, PATHINFO_FILENAME); 572 573 $cache_name = pathinfo($filename, PATHINFO_FILENAME); 574 575 $cache_name = explode('_8', $cache_name); 576 577 // move XML to cache file position 578 $cache_name = getCacheName($cache_name[0], ".xml"); 579 580 copy($file, $cache_name); 581 } 582 583 return $returnVar === 0; 584 } 585 586 587 /** 588 * The function _getXMLOutputName takes a filename 589 * in the Doxygen workspace and converts it to the output XML filename. 590 * 591 * @todo: can probably be deleted! 592 * 593 * @param string Name of a source file in the doxygen workspace. 594 * 595 * @return string Name of the XML file output by Doxygen for the given source file. 596 */ 597 private function getXMLOutputName($filename) 598 { 599 return str_replace(".", "_8", $filename) . '.xml'; 600 } 601 602 /** 603 * The function creates a temporary directory for building the Doxygen documentation. 604 * 605 * @return string Directory where Doxygen can be executed. 606 */ 607 private function createTaskDir($taskID) 608 { 609 global $conf; 610 611 $tempDir = $conf['tmpdir'] . '/doxycode/'; 612 613 // check if we already have a doxycode directory 614 if (!is_dir($tempDir)) { 615 mkdir($tempDir); 616 } 617 618 $tempDir .= $taskID; 619 if (!is_dir($tempDir)) { 620 mkdir($tempDir); 621 } 622 623 return $tempDir; 624 } 625 626 /** 627 * Delete the temporary build task directory. 628 * 629 * @param String $dirPath Build directory where doxygen is executed. 630 */ 631 private function deleteTaskDir($dirPath) 632 { 633 if (! is_dir($dirPath)) { 634 throw new InvalidArgumentException("$dirPath must be a directory"); 635 } 636 637 io_rmdir($dirPath, true); 638 } 639 640 /** 641 * Create the source file in the build directory of the build task. 642 * 643 * The $config variable includes the corresponding TaskID. 644 * First we try to create the temporary build directory. 645 * 646 * We than place the content of the code snippet in a source file in the build directory. 647 * 648 * The extension of the source file is the 'language' variable in $config. 649 * The filename of the source file is the cache ID (=JobID) of the XML cache file. 650 * This can later be used to place the doxygen output XML at the appropriate XML cache file name. 651 * 652 * @param String $jobID JobID for this build job 653 * @param Array &$config Configuration from the snippet syntax 654 * @param String $content Content from the code snippet 655 */ 656 private function createJobFile($jobID, &$config, $content) 657 { 658 // if we do not already have a job directory, create it 659 $tmpDir = $this->createTaskDir($config['taskID']); 660 661 if (!is_dir($tmpDir)) { 662 return ''; 663 } 664 665 // we expect a cache filename (md5) - the xml output from the build job will have this filename 666 // thereby the doxygen builder can correctly identify where the XML output file should be placed 667 // getCacheName() can later be used to get the correct place for the cache file 668 $render_file_name = $jobID . '.' . $config['language']; 669 670 // Attempt to write the content to the file 671 $result = file_put_contents($tmpDir . '/' . $render_file_name, $content); 672 673 // TODO: maybe throw error here and try catch where used... 674 675 return $tmpDir; 676 } 677 678 /** 679 * Get a list of scheduled build tasks from the Tasks table in sqlite. 680 * 681 * @param Num $amount Amount of build tasks to return. 682 * @return Array Build tasks 683 */ 684 public function getBuildTasks($amount = PHP_INT_MAX) 685 { 686 // get build tasks from SQLite 687 // the order should be the same for all functions 688 // first one is the one currently built or the next one do build 689 if ($this->db === null) { 690 return false; 691 } 692 693 // get the oldest task first 694 $rows = $this->db->queryAll( 695 'SELECT TaskID FROM Tasks WHERE TaskID IS NOT NULL AND State = ? ORDER BY Timestamp ASC LIMIT ?', 696 [self::STATE_SCHEDULED, $amount] 697 ); 698 699 700 return $rows; 701 } 702 703 704 /** 705 * Filter the doxygen relevant attributes from a configuration array. 706 * 707 * The doxygen relevant attributes are parameters that are passed to doxygen when building. 708 * Examples: tag file configuration 709 * 710 * The configuration also includes attributes that only influence task scheduling (e.g. 'render_task' which forces 711 * task runner build from the syntax). Here we filter those values out. 712 * 713 * This function is especially useful for generating the cache file IDs. 714 * 715 * @param Array $config Configuration from the snippet syntax. 716 * @param bool $exclude Return only doxygen relevant configuration or everying else 717 * @return Array filtered configuration 718 */ 719 public function filterDoxygenAttributes($config, $exclude = false) 720 { 721 $filtered_config = []; 722 723 // filter tag_config by tag_names 724 if (!$exclude) { 725 $filtered_config = array_intersect_key($config, array_flip($this->conf_doxygen_keys)); 726 } else { 727 $filtered_config = array_diff_key($config, array_flip($this->conf_doxygen_keys)); 728 } 729 730 731 // filter out keys that are only relevant for the snippet syntax 732 $filtered_config = array_diff_key($filtered_config, array_flip($this->conf_doxycode_keys)); 733 734 $filtered_config = is_array($filtered_config) ? $filtered_config : [$filtered_config]; 735 736 return $filtered_config; 737 } 738} 739