<?php
namespace Tlf;
/**
* Central class for running scrawl.
*/
class Scrawl {
/** array for get/set */
public array $stuff = [];
public array $extensions = [
'code'=>[],
];
/**
* Array of \Tlf\Scrawl\Extension objects
*/
public array $ScrawlExtensions = [];
/**
* \Tlf\Scrawl\Ext\MdVerbs
*/
public \Tlf\Scrawl\Ext\MdVerbs $mdverb_ext;
/** absolute path to php file to `require()` before scrawl runs */
public ?string $file_bootstrap = null;
/** absolute path to your documentation dir */
public ?string $dir_docs = null;
/** absolute path to the root of your project */
public ?string $dir_root = null;
/** absolute path to the documentation source dir */
public ?string $dir_src = null;
/** array of relative path to dirs to scan, within your dir_root */
public ?array $dir_scan = [];
/** array of handlers for the mdverb extension */
public array $verb_handlers = [];
/** absolute path to directories that contain md templates */
public array $template_dirs = [];
/** if true, append two spaces to every line so all new lines are parsed as new lines */
public bool $markdown_preserveNewLines = true;
/** if true, add an html comment to md docs saying not to edit directly */
public bool $markdown_prependGenNotice = true;
/** if true, copies `docs/README.md` to project root `README.md` */
public bool $readme_copyFromDocs = true;
/** If true will delete all files in your docs dir before running */
public bool $deleteExistingDocs = false;
/** Which directory to write Class/Method/Property info to */
public ?string $api_output_dir = 'api/';
/** True to generate a README listing all classes, inside the api_output_dir */
public bool $api_generate_readme = true;
/**
* Array of values, typically passed in through cli
*/
public array $options = [];
/**
* @parma $options `key=>value` array to set properties
*/
public function __construct(array $options=[]){
$this->options = $options;
foreach ($this->options as $k=>$o){
$k = str_replace('.','_',$k);
if (property_exists($this,$k)){
$this->$k = $o;
}
}
$this->template_dirs[] = __DIR__.'/Template/';
$this->mdverb_ext = $this->setup_mdverb_ext();
$this->ScrawlExtensions = $this->setup_extensions($this->options['ScrawlExtensions']??[]);
}
/**
* Get the relative path within target_path, if it starts with root_path
*
* @experimental has not been tested
* @param $base_path the base path, to remove from target path
* @param $target_path a path that starts with `$base_path` and contains a relative path you're parsing.
* @return the relative path, or null if target path does not start with base path
*/
public function parse_rel_path(string $base_path, string $target_path, bool $use_realpath = true): ?string {
$abs_base = $use_realpath ? realpath($base_path) : $base_path;
$abs_target = $use_realpath ? realpath($target_path) : $target_path;
$len = strlen($abs_base);
// var_dump($abs_base);
// var_dump($abs_target);
if (substr($abs_target, 0, $len) != $abs_base)return null;
return substr($abs_target,$len);
}
/**
*
* Cli function to get the absolute path to a code file
*
* @param $args array of arguments
* @key 1, code_file_path - absolute path to documentation of a code file. Example "/absolute/path.php" might return "/absolute/docs/path.md"
* @usage `$scrawl->get_doc_path(new \Tlf\Cli(), "/absolute/path");
*
* @return absolute path or an empty string
*/
public function get_doc_path(\Tlf\Cli $cli, array $args): string {
$code_file_path = $args['--'][0];
// echo "\nPath: $code_file_path\n";
// echo "\nRealPath: ".realpath($code_file_path)."\n";
$code_file_path = realpath($code_file_path);
// print_r($args);
$cwd = getcwd();
// echo "\nCurDir: ".$cwd."\n";
if ($cwd!=substr($code_file_path,0,strlen($cwd))){
return "";
}
$rel_path = substr($code_file_path,strlen($cwd));
$rel_path = str_replace('../','/', $rel_path);
$abs_path = $this->dir_docs.($this->api_output_dir??'api/').$rel_path.'.md';
$abs_path = str_replace('//','/', $abs_path);
echo "$abs_path";
return $abs_path;
// echo "AbsPath:".$abs_path;
//
// echo "\nRelPath: $rel_path\n";
//
//
// return "\nPath: $code_file_path\n";
}
/**
*
* Cli function to get the absolute path to a documentation source file for a .md file
*
* @param $args array of arguments
* @key 1, code_file_path - absolute path to documentation of a code file. Example "/absolute/path.php" might return "/absolute/docs/path.md"
* @usage `$scrawl->get_doc_path(new \Tlf\Cli(), "/absolute/path");
*
* @return absolute path or an empty string
*/
public function get_doc_source_path(\Tlf\Cli $cli, array $args): string {
$code_file_path = $args['--'][0];
// echo "\nPath: $code_file_path\n";
// echo "\nRealPath: ".realpath($code_file_path)."\n";
$code_file_path = realpath($code_file_path);
// print_r($args);
$cwd = getcwd();
// echo "\nCurDir: ".$cwd."\n";
if ($cwd!=substr($code_file_path,0,strlen($cwd))){
return "";
}
if ($code_file_path == realpath($this->dir_root.'/README.md')){
$path = $this->dir_src.'/README.src.md';
echo $path;
return $path;
}
$rel_path = substr($code_file_path,strlen($this->dir_docs));
$rel_path = str_replace('../','/', $rel_path);
$abs_path = $this->dir_src.'/'.$rel_path;
$abs_path = str_replace('//','/', $abs_path);
// replace .md with .src.md
$abs_path = substr($abs_path, 0,-3) . '.src.md';
echo "$abs_path";
return $abs_path;
// echo "AbsPath:".$abs_path;
//
// echo "\nRelPath: $rel_path\n";
//
//
// return "\nPath: $code_file_path\n";
}
/**
* Get a template stored on disk. `$name` should be relative path without extension. `$args` is passed to template code, but not `extract`ed. Template files must end with `.md.php` or just `.php`.
*
*/
public function get_template(string $name, array $args){
foreach ($this->template_dirs as $path){
if (file_exists($file = $path.'/'.$name.'.md.php')){}
else if (file_exists($file=$path.'/'.$name.'.php')){}
else continue;
$out = (function(array $args, string $file) {
ob_start();
require($file);
$out = ob_get_clean();
return $out;
})($args, $file);
return $out;
}
$this->warn("@template", $msg="Template '$name' does not exist.");
return $msg;
}
public function get(string $group, string $key){
if (!isset($this->stuff[$group])){
$this->warn("Group not set", $group);
return null;
} else if (!isset($this->stuff[$group][$key])){
$this->warn("Group.Key not set", "$group.$key");
return null;
}
return $this->stuff[$group][$key];
}
public function get_group(string $group){
if (!isset($this->stuff[$group])){
$this->warn("Group not set", $group);
return null;
}
return $this->stuff[$group];
}
public function set(string $group, string $key, $value){
$this->stuff[$group][$key] = $value;
}
public function parse_str($str, $ext){
$out = [];
foreach ($this->extensions['code'][$ext] as $ext){
$out = $ext->parse_str($str);
}
return $out;
}
/**
* save a file to disk in the documents directory
*/
public function write_doc(string $rel_path, string $content){
$content = $this->prepare_md_content($content);
$rel_path = str_replace('../','/', $rel_path);
$path = $this->dir_docs.'/'.$rel_path;
$dir = dirname($path);
if (!is_dir($dir))mkdir($dir,0755,true);
if (is_file($path)){
$this->good('Overwrite',$rel_path);
} else {
$this->good('Write',$rel_path);
}
file_put_contents($path, $content);
}
/**
* save a file to disk in the root directory
*/
public function write_file(string $rel_path, string $content){
$rel_path = str_replace('../','/', $rel_path);
$path = $this->dir_root.'/'.$rel_path;
$dir = dirname($path);
if (!is_dir($dir))mkdir($dir,0755,true);
if (is_file($path)){
$this->good('Overwrite',$rel_path);
} else {
$this->good('Write',$rel_path);
}
file_put_contents($path, $content);
}
/**
* Read a file from disk, from the project root
*/
public function read_file(string $rel_path){
return file_get_contents($this->dir_root.'/'.$rel_path);
}
/**
* Read a file from disk, from the project docs dir
*/
public function read_doc(string $rel_path){
return file_get_contents($this->dir_docs.'/'.$rel_path);
}
/** get a path to a docs file */
public function doc_path(string $rel_path){
return $this->dir_docs.'/'.$rel_path;
}
/**
* Output a message to cli (may do logging later, idk)
*/
public function report(string $msg){
echo "\n$msg";
}
/**
* Output a message to cli, header highlighted in red
*/
public function warn($header, $message){
echo "\033[0;31m$header:\033[0m $message\033[0;31m\033[0m\n";
}
/**
* Output a message to cli, header highlighted in red
*/
public function good($header, $message){
echo "\033[0;32m$header:\033[0m $message\033[0;31m\033[0m\n";
}
/** apply small fixes to markdown */
public function prepare_md_content(string $markdown){
if ($this->markdown_preserveNewLines){
$markdown = str_replace("\n"," \n",$markdown);
}
if ($this->markdown_prependGenNotice){
// @TODO give relative path to source file
$markdown = "<!-- DO NOT EDIT. This file generated from template by Code Scrawl https://tluf.me/php/code-scrawl/ --> \n".$markdown;
}
return $markdown;
}
public function get_all_docsrc_files(){
$files = \Tlf\Scrawl\Utility\Main::allFilesFromDir($this->dir_src, '');
return $files;
}
/**
* get array of all files in `$scrawl->dir_scan`
* @return array or relative paths within `$scrawl->dir_scan`
*/
public function get_all_scan_files(): array{
$all = [];
foreach ($this->dir_scan as $f){
$files = \Tlf\Scrawl\Utility\Main::allFilesFromDir($this->dir_root, $f);
$all = array_merge($all, $files);
}
return $all;
}
/**
* Generate api docs for all files
* (currently only php files)
*/
public function generate_apis() {
if ($this->api_output_dir === null
|| $this->api_output_dir === false
)return;
foreach ($this->get_all_scan_files() as $file){
$this->generate_api($file);
}
if ($this->api_generate_readme){
$this->generate_apis_readme();
}
}
/**
* Create a README file that lists all of the classes in the API dir.
*/
public function generate_apis_readme(){
$this->report("Generate README for APIs");
$markdown = $this->get_template('ast/ApiReadme', [$this->get_all_classes(), $this->get_all_traits()]);
$this->write_doc($this->api_output_dir.'/README.md', $markdown);
}
/**
* Generate api doc for a single file
* (currently only php files)
*
* @param $rel_path relative path inside `$scrawl->dir_root`
*/
public function generate_api($rel_path){
if (strtolower(pathinfo($rel_path,PATHINFO_EXTENSION))!=='php')return;
$php_ext = new \Tlf\Scrawl\FileExt\Php($this);
$ast = $php_ext->parse_file($rel_path);
$path = $ast['path'];
// $rel_path = substr($path, strlen($scrawl->dir_root));
// $ast['path'] = $rel_path;
// $scrawl->set('ast','file.'.$rel_path, $ast);
$classes = array_merge($ast['class'] ?? [], $ast['namespace']['class'] ?? []);
if (count($classes)==0)return;
$doc = "# File ".$rel_path."\n";
foreach ($classes as $c){
$markdown = $this->get_template('ast/class', [null,$c,null]);
$doc .="\n".$markdown;
}
$this->write_doc($this->api_output_dir.'/'.$rel_path.'.md', $doc);
}
/**
* Get an array of all classes scanned within this repo.
*
* @return array<string fully_qualified_classname, array $ast> ASTs for each class scanned within the repo.
*/
public function get_all_classes(): array {
$php_ext = new \Tlf\Scrawl\FileExt\Php($this);
$files = $this->get_all_scan_files();
$classes = [];
$trait_count = 0;
foreach ($files as $f){
if (strtolower(pathinfo($f,PATHINFO_EXTENSION))!=='php')continue;
$this->report("Generate Ast: ".$f);
$ast = $php_ext->parse_file($f);
$trait_count += count($ast['trait']??[]) + count($ast['namespace']['trait']??[]);
$new_classes = array_merge($ast['class'] ?? [], $ast['namespace']['class'] ?? []);
foreach ($new_classes as &$c){
$c['file'] = $f;
$classes[$c['fqn']] = $c;
}
}
return $classes;
}
/**
* Get an array of all classes scanned within this repo.
*
* @return array<string fully_qualified_classname, array $ast> ASTs for each class scanned within the repo.
*/
public function get_all_traits(): array {
$php_ext = new \Tlf\Scrawl\FileExt\Php($this);
$files = $this->get_all_scan_files();
$traits = [];
$trait_count = 0;
foreach ($files as $f){
if (strtolower(pathinfo($f,PATHINFO_EXTENSION))!=='php')continue;
$this->report("Generate Ast: ".$f);
$ast = $php_ext->parse_file($f);
$new_traits = array_merge($ast['trait'] ?? [], $ast['namespace']['trait'] ?? []);
foreach ($new_traits as &$c){
$c['file'] = $f;
$traits[$c['fqn']] = $c;
}
}
return $traits;
}
/**
* Array of \Tlf\Scrawl\Extension objects
* @param $extensions_classes an array of class names implementing `\Tlf\Scrawl\Extension`
* @return array of instantiated objects
*/
public function setup_extensions(array $extension_classes){
$extensions = [];
foreach ($extension_classes as $ec){
if (!class_exists($ec, true)){
$this->report("Extension class '$ec' does not exist.");
continue;
}
/* if (substr($ec,0,1)=='\\')$ec = substr($ec,1); */
if (!in_array('Tlf\\Scrawl\\Extension',class_implements($ec,true))){
$this->report("Extension class '$ec' does not exist.");
continue;
}
$extensions[] = new $ec($this);
}
return $extensions;
}
/**
* Execute scrawl in its entirety
*/
public function run(){
// bootstrap via file
$scrawl = null;
if ($this->file_bootstrap!=null&&file_exists($this->file_bootstrap)){
$scrawl = $this;
require_once($this->file_bootstrap);
}
unset($scrawl);
// bootstrap extensions
foreach ($this->ScrawlExtensions as $se){
$se->bootstrap();
}
// delete existing documentation
if ($this->deleteExistingDocs){
$del_dir = realpath($this->dir_docs);
$cwd = realpath(getcwd());
$len = strlen($cwd);
if (substr($del_dir,0,$len)===$cwd
&& strlen($cwd)>6
&&count(explode('/',$cwd))>=4
){
$this->warn("Delete Dir", $del_dir);
\Tlf\Scrawl\Utility\Main::DANGEROUS_removeNonEmptyDirectory($del_dir);
}
}
$this->report("Generate Asts");
//////////
// process php files into 'ast'
//////////
$classes = $this->get_all_classes();
foreach ($classes as $c){
$this->set('ast', 'class.'.$c['fqn'], $c);
foreach ($this->ScrawlExtensions as $se){
$se->ast_generated($c['fqn'], $c);
}
}
$this->report("### Generate APIs ###\n");
$this->generate_apis();
// call extensions for ast generated
foreach ($this->ScrawlExtensions as $se){
$se->astlist_generated($this->get_group('ast')??[]);
}
//////////
// process all code files using code extensions
//////////
$export_docblock = new \Tlf\Scrawl\FileExt\ExportDocBlock();
$export_startend = new \Tlf\Scrawl\FileExt\ExportStartEnd();
// $php_ext = new \Tlf\Scrawl\FileExt\Php();
$code_files = $this->get_all_scan_files();
// call extensions for all files
foreach ($this->ScrawlExtensions as $se){
$se->scan_filelist_loaded($code_files);
}
// var_dump($code_files);
// exit;
foreach ($code_files as $f){
$path = $this->dir_root.'/'.$f;
$file_content = file_get_contents($path);
$file_exports = [];
// docblock @export()s
$docblocks = $export_docblock->get_docblocks($file_content);
$exports = $export_docblock->get_exports($docblocks);
foreach ($exports as $k=>$e)$this->set('export',$k, $e);
$file_exports = $exports;
// @export_start/@export_end()
$exports = $export_startend->get_exports($file_content);
$file_exports = array_merge($file_exports, $exports);
foreach ($exports as $k=>$e)$this->set('export',$k, $e);
foreach ($this->ScrawlExtensions as $se){
$se->scan_file_processed($path, $f, $file_content, $file_exports??[]);
}
}
foreach ($this->ScrawlExtensions as $se){
$se->scan_filelist_processed($code_files, $this->get_group('export') ?? []);
}
//////////
// process all documentation source files
//////////
$src_files = $this->get_all_docsrc_files();
$mdverb_ext = $this->mdverb_ext;
foreach ($this->ScrawlExtensions as $se){
$se->doc_filelist_loaded($src_files, $mdverb_ext);
}
foreach ($src_files as $sf){
$path = $this->dir_src.'/'.$sf;
// $sf contains a leading slash, i guess?
if ($path==$this->dir_src.'//config.json'){
continue;
}
$content = file_get_contents($path);
// process mdverbs
foreach ($this->ScrawlExtensions as $se){
$se->doc_file_loaded($path,$sf,$content);
}
$content = $mdverb_ext->replace_all_verbs($content);
if (substr($sf,-7)=='.src.md')$sf = substr($sf,0,-7).'.md';
$this->write_doc($sf, $content);
foreach ($this->ScrawlExtensions as $se){
$se->doc_file_processed($this->dir_docs.'/'.$sf,$sf,$content);
}
}
foreach ($this->ScrawlExtensions as $se){
$se->doc_filelist_processed($src_files);
}
$readme_path = $this->doc_path('README.md');
if ($this->readme_copyFromDocs && file_exists($readme_path)){
$this->write_file('README.md', $this->read_doc('README.md'));
}
$this->good("Finished",'Code Scrawl Ran');
foreach ($this->ScrawlExtensions as $se){
$se->scrawl_finished();
}
}
/**
* get an array ast from a file
* Currently only supports php files
* Also sets the ast to scrawl
*/
public function get_ast(string $file): ?array {
$ext = pathinfo($file, PATHINFO_EXTENSION);
if ($ext!='php'){
$this->report("File '$file' not .php. Skip ast parse.");
return null;
}
$php_ext = new \Tlf\Scrawl\FileExt\Php($this);
$ast = $php_ext->parse_file($file);
// $php_ext->set_ast($ast);
return $ast;
}
/** get the class ast
* @param $class fully qualified class name
*/
public function get_class_ast(string $class): ?array{
$ast = $this->get('ast','class.'.$class);
return $ast;
}
public function setup_mdverb_ext(){
$mdverb_ext = new \Tlf\Scrawl\Ext\MdVerbs($this);
$main_verbs_ext = new \Tlf\Scrawl\Ext\MdVerb\MainVerbs($this);
$main_verbs_ext->setup_handlers($mdverb_ext);
$ast_ext = new \Tlf\Scrawl\Ext\MdVerb\Ast($this);
$mdverb_ext->handlers['ast'] = [$ast_ext, 'get_markdown'];
foreach ($this->verb_handlers as $k=>$v){
$mdverb_ext->handlers[$k] = $v;
}
return $mdverb_ext;
}
// public function call_extensions($hook){
//
// }
}