#!/usr/bin/php
<?php
define( 'APP_PATH', dirname( realpath( __FILE__ ) ) );

set_include_path( APP_PATH );
require_once( 'parser.php' );
require_once( 'ncurses/ncurses.php' );

define( 'FAULT_MINOR', 0 );
define( 'FAULT_MEDIUM', 1 );
define( 'FAULT_MAJOR', 2 );

$modules = array(
	'scanner' => array(),
	'output' => new OutputModule()
);
$stderr = fopen( 'php://stderr', 'w' );
$config = array(
	'svn' => false,
	'modules' => array(),
	'output_format' => 'text',
	'output_file' => 'php://stdout',
	'quiet' => false,
);
$help = "Usage:
{$argv[0]} [options] file|path [file|path ...]
{$argv[0]} [options] -b base_path -r revision[,revision ...]

Options:
    -b path                 Set the base path for the scan. Useful if you want
    --base-path path        to scan individual files in a code base that don't
                            live in the project's base directory.

    --curses                Enables the ncurses GUI

    -f format               Select the output format. Defaults to text.
    --format format

    -h                      Display this usage information
    --help

    -m modulename           Loads the requested scanning module. If this
    --module modulename     parameter is not specified, all available modules
                            will be loaded.

    -o filename             Write output to a file instead of stdout
    --output filename

    -q                      Suppresses all progress output
    --quiet

    -r                      Get files from a comma separated list of SVN revisions.
                            You must supply a base path first for this!

    --svn                   Enables SVN integration
";
$faults = array();

class ScannerModule {
	var $faults;
	var $blame;
	var $file;
	
	function ScannerModule() {
		$this->faults = array();
		$this->blame = array();
		err( "Initializing " . get_class( $this ) . "...\n" );
	}
	function fault( $object, $level, $reason = '', $force = false ) {
		global $config, $revisions, $faults;
		if( !$force && $this->file['revision'] > 0 && $this->blame[$object['line']]['revision'] != $this->file['revision'] ) {
			/*	If files have been added using SVN revisions, filter out any faulty
				changes that aren't a part of the requested changeset(s).
			*/
			return false;
		}
		$object['file'] = filename( $object['file'] );
		$faults[] = $this->faults[] = array(
			'module'	=> get_class( $this ),
			'object'	=> $object,
			'file'		=> $object['file'],
			'line'		=> $object['line'],
			'level'		=> $level,
			'reason'	=> $reason,
			'author'	=> ( $config['svn'] === true ) ? $this->blame[$object['line']]['author'] : '',
			'revision'	=> ( $config['svn'] === true ) ? $this->blame[$object['line']]['revision'] : ''
		);
	}
	function parserCallback( $object ) {
	}
	function preScan( $file ) {
		global $config, $svn_root, $svn_base;
		$this->file = $file;
		if( $config['svn'] === true ) {
			$this->blame = array();
			$output = array();
			if( $file['revision'] > 0 ) {
				exec( "svn blame -r {$file['revision']} {$svn_root}{$svn_base}/{$file['filename']} 2>/dev/null", $output, $result );
			} else {
				exec( "svn blame '{$file['filename']}' 2>/dev/null", $output, $result );
			}
			if( $result == 0 ) {
				foreach( $output as $line => $text ) {
					$matches = array();
					preg_match( '/^\s*(\d+)\s+([^\s]+)/', $text, $matches );
					$this->blame[$line + 1] = array(
						'author' => $matches[2],
						'revision' => $matches[1]
					);
				}
			}
		}
	}
	function postScan( $filename ) {
	}
}

class OutputModule {
	function display() {
		$this->write( 'php://output' );
	}
	function write( $filename ) {
	}
}

function _callback( $object ) {
	global $modules, $files, $current_file;
	$object['file'] = $files[$current_file - 1]['filename'];
	foreach( $modules['scanner'] as $module ) {
		$module->parserCallback( $object );
	}
}
function addModule( $module_instance ) {
	global $modules;
	if( $module_instance instanceof ScannerModule ) {
		$modules['scanner'][] = $module_instance;
	} elseif( $module_instance instanceof OutputModule ) {
		$modules['output'] = $module_instance;
	}
}
function filename( $filename ) {
	global $base_path;
	$filename = realpath( $filename );
	if( strpos( $filename, $base_path ) === 0 ) {
		$filename = substr( $filename, strlen( $base_path ) );
	}
	return $filename;
}
function err( $string ) {
	global $stderr, $config, $curses;
	if( $config['quiet'] === false && $curses === false ) {
		fputs( $stderr, $string );
	}
}
function scan_progress( $pos, $max, $title ) {
	global $curses, $nc_progress, $nc_progress_bar;
	
	$title = !empty( $title ) ? $title : 'Scanning [%d/%d]';
	$title = @sprintf( $title, $pos, $max );
	if( $curses ) {
		$nc_progress->write_fixed( 0, 1, $title );
		$nc_progress_bar->max( $max );
		$nc_progress_bar->pos( $pos );
	}
}
// Handle application arguments
$revisions = array();
$files = array();
$base_path = false;
$curses = false;
for( $i = 1; $i < $argc; $i++ ) {
	switch( $argv[$i] ) {
		case '-b':
		case '--base-path':
			$new_base = $argv[++$i];
			if( is_dir( $new_base ) ) {
				$base_path = realpath( $new_base ) . '/';
			}
			break;
		case '--curses':
			$curses = function_exists( 'ncurses_init' );
			break;
		case '-f':
		case '--format':
			$config['output_format'] = $argv[++$i];
			break;
		case '-h':
		case '--help':
			die( $help );
			break;
		case '-m':
		case '--module':
			$config['modules'] = array_merge($config['modules'], explode(',', $argv[++$i]));
			break;
		case '-o':
		case '--output':
			$config['output_file'] = $argv[++$i];
			break;
		case '-q':
		case '--quiet':
			$config['quiet'] = true;
			break;
		case '-r':
			$revs = explode( ',', $argv[++$i] );
			if( $base_path === false ) {
				die( "Set a base path before supplying SVN revisions\n" );
			}
			// First, find out what the full path is so we can trim it down to the relative one.
			$xml = shell_exec( "svn info --xml $base_path" );
			$parser = xml_parser_create();
			xml_parser_set_option( $parser, XML_OPTION_SKIP_WHITE, 1 );
			xml_parse_into_struct( $parser, $xml, $values, $index );
			xml_parser_free( $parser );
			foreach( $values as $value ) {
				switch( $value['tag'] ) {
					case 'URL':
						$svn_url = $value['value'];
						break;
					case 'ROOT':
						$svn_root = $value['value'];
				}
			}
			$svn_base = substr( $svn_url, strlen( $svn_root ) );
			foreach( $revs as $rev ) {
				$revisions[] = $rev = intval( $rev );
				$xml = shell_exec( "svn log -v --xml -r $rev $base_path 2>/dev/null" );
				$parser = xml_parser_create();
				xml_parser_set_option( $parser, XML_OPTION_SKIP_WHITE, 1 );
				xml_parse_into_struct( $parser, $xml, $values, $index );
				xml_parser_free( $parser );
				foreach( $values as $value ) {
					if(
						$value['tag'] == 'PATH'
						&& isset( $value['attributes']['ACTION'] )
						&& in_array( $value['attributes']['ACTION'], array( 'A', 'M' ) )
						&& strtolower( substr( $value['value'], -4 ) ) == '.php'
					) {
						$file = substr( $value['value'], strlen( $svn_base ) + 1 );
						$files[] = array(
							'filename' => $file,
							'revision' => $rev,
							'contents' => shell_exec( "svn cat -r {$rev} {$svn_root}{$value['value']} 2>/dev/null" )
						);
					}
				}
			}
		case '--svn':
			$config['svn'] = true;
			break;
		default:
			if( file_exists( $argv[$i] ) && strtolower( substr( $argv[$i], -4 ) ) == '.php' ) {
				$base_path = ( $base_path === false ) ? realpath( dirname( $argv[$i] ) ) . '/' : $base_path;
				$files[] = array(
					'filename' => $argv[$i],
					'revision' => 0
				);
			} else if( is_dir( $argv[$i] ) ) {
				$base_path = ( $base_path === false ) ? realpath( $argv[$i] ) . '/' : $base_path;
				exec( "find {$argv[$i]} -iname '*.php' 2>/dev/null", $output, $result );
				foreach( $output as $file ) {
					$files[] = array(
						'filename' => $file,
						'revision' => 0
					);
				}
			}
	}
}
if (empty($config['modules'])) $config['modules'] = array('lint', 'functions', 'variables', 'pattern');

if( count( $files ) == 0 ) {
	if( count( $revisions ) > 0 ) {
		die( "Revisions invalid or contained no files from the supplied path\n" );
	} else {
		die( $help );
	}
}

if( $curses ) {
	ncurses_init();
	ncurses_noecho();
	ncurses_curs_set( false );
	$nc_screen = ncurses_newwin( 0, 0, 0, 0 );
	ncurses_refresh();
	
	$nc_main = new NcWindow( $nc_screen, 0, 0, 0, 0 );
	$nc_main->title( 'Code Scanner' );
	$nc_progress = new NcWindow( $nc_main, 4, 0, 0, 0 );
	$nc_progress->title( 'Scanning Progress' );
	$nc_progress_bar = new NcProgressBar( $nc_progress, 0, 1, 0 );
	$nc_faults_columns = array(
		'module' => 'Module',
		'level' => 'Level',
		'file' => 'File',
		'line' => 'Line',
		'author' => 'Author',
		'revision' => 'Revision',
		'reason' => 'Reason',
	);
	$nc_faults = new NcTableView( $nc_main, -5, 0, 4, 0, array( 'columns' => $nc_faults_columns ) );
	$nc_faults->title( 'Faults' );
	$nc_main->write( -1, 0, '[Q] Quit | [S] Save Output file | [Return] Fault Info' );
}

// Dig into the modules folder and load up what we find
$module_files = scandir( APP_PATH . '/modules' );
foreach( $module_files as $module_file ) {
	if( strtolower( substr( $module_file, -4 ) ) == '.php' ) {
		$module = substr( $module_file, 0, strlen( $module_file ) - 4 );
		list( $type, $module ) = split( '_', $module );
		switch( $type ) {
			case 'output':
				if( $module == $config['output_format'] ) {
					require_once( APP_PATH . "/modules/{$module_file}" );
				}
				break;
			case 'scanner':
				if(
					count( $config['modules'] ) == 0
					|| in_array( $module, $config['modules'] )
				) {
					require_once( APP_PATH . "/modules/{$module_file}" );
				}
				break;
		}
	}
}
$parser = new PHPParser();
$parser->registerCallback( '_callback' );

err( "Parsing files...\n" );
$current_file = 0; $total = count( $files );

foreach( $files as $file ) {
	$current_file++;
	scan_progress( $current_file, $total, 'Scanning %d of %d source files' );
	if( $curses ) {
		// Do this to avoid getting fouled up during scanning...
		// Apparently, system calls (like exec(), used while scanning) break curses keyboard input somehow
		ncurses_def_prog_mode();
	}
	foreach( $modules['scanner'] as $module ) { $module->preScan( $file ); }
	$file_contents = ( $file['revision'] > 0 ? shell_exec( "svn cat -r {$rev} {$svn_root}{$svn_base}/{$file['filename']} 2>/dev/null" ) : file_get_contents( $file['filename'] ) );
	$parser->parse( $file_contents );
	foreach( $modules['scanner'] as $module ) { $module->postScan( $file ); }
	if( $curses ) {
		ncurses_reset_prog_mode();
		$nc_faults->title( sprintf( 'Faults [%d found]', count( $faults ) ) );
	}
}
err( "\n" );

if( $curses ) {
	$nc_faults->load( $faults );
	$done = false;
	while( !$done ) {
		$key = $nc_main->get_char();
		switch( $key ) {
			case NCURSES_KEY_UP:
				$nc_faults->update( $nc_faults->selected - 1 );
				break;
			case NCURSES_KEY_DOWN:
				$nc_faults->update( $nc_faults->selected + 1 );
				break;
			case NCURSES_KEY_PPAGE:
				$nc_faults->update( $nc_faults->selected - $nc_faults->height() - 1 );
				break;
			case NCURSES_KEY_NPAGE:
				$nc_faults->update( $nc_faults->selected + $nc_faults->height() - 1 );
				break;
			case 10:
			case 13:
				if( count( $nc_faults->records ) > 0 ) {
					$nc_fault_info = new NcWindow( $nc_main, -10, -10, 5, 5 );
					$fault = &$faults[$nc_faults->selected];
					$nc_fault_info->attribute_on( NCURSES_A_BOLD );
					$nc_fault_info->write_centered( 0, 'Fault Information' );
					$nc_fault_info->write( 3, 0, "Module:\nLevel:\nFilename:\nLine Number:\nAuthor:\nRevision:\nReason:\n\nContext:" );
					$nc_fault_info->attribute_off( NCURSES_A_BOLD );
					$fault['object']['context'] = trim( $fault['object']['context'] );
					$nc_fault_info->write( 3, 20, "{$fault['module']}\n{$fault['level']}\n{$fault['file']}\n{$fault['line']}\n{$fault['author']}\n{$fault['revision']}\n{$fault['reason']}\n\n{$fault['object']['context']}" );
					$nc_fault_info->get_char();
					$nc_fault_info->hide();
					unset( $nc_fault_info );
				}
				break;
			case ord( 's' ):
			case ord( 'S' );
				$nc_save = new NcWindow( $nc_main, -10, -10, 5, 5 );
				$nc_save->attribute_on( NCURSES_A_BOLD );
				$nc_save->write_centered( 0, 'Save Faults To File' );
				$nc_save->attribute_off( NCURSES_A_BOLD );
				$nc_save->write( 2, 0, 'File to save to:' );
				$nc_save_file_input = new NcTextInput( $nc_save, -4, 3, 2 );
				$filename_selected = false;
				while( !$filename_selected ) {
					$filename = $nc_save_file_input->get();
					$nc_save_confirm = new NcWindow( $nc_save, -10, -10, 5, 5 );
					$nc_save_confirm->title( get_class( $modules['output'] ) );
					$nc_save_confirm->write_centered( floor( $nc_save_confirm->height() / 2 ) - 2, "Save results to '{$filename}'?" );
					$nc_save_confirm->write_centered( floor( $nc_save_confirm->height() / 2 ), '[Y]es   [N]o   [C]ancel' );
					$valid_key = false;
					while( !$valid_key ) {
						switch( $nc_save_confirm->get_char() ) {
							case ord( 'y' ):
							case ord( 'Y' ):
								$save_file = true;
							case ord( 'c' ):
							case ord( 'C' ):
								$filename_selected = true;
							case ord( 'n' ):
							case ord( 'N' ):
								$valid_key = true;
						}
					}
					$nc_save_confirm->hide();
				}
				if( $save_file ) {
					$result = $modules['output']->write( $filename );
					$save_message = ( $result === false ) ? 'Save Failed' : 'Saved successfully';
					$nc_save_confirm->erase();
					$nc_save_confirm->write_centered( floor( $nc_save_confirm->height() / 2 ) - 2, $save_message );
					$nc_save_confirm->write_centered( floor( $nc_save_confirm->height() / 2 ), 'Press any key to continue' );
					$nc_save_confirm->show();
					$nc_save_confirm->get_char();
				}
				$nc_save->hide();
				unset( $nc_save );
				break;
			case ord( 'q' ):
			case ord( 'Q' ):
				$done = true;
				break;
		}
	}
	ncurses_clear();
	ncurses_refresh();
	ncurses_end();
} else {
	$modules['output']->write( $config['output_file'] );
	if (false === $config['quiet']) {
		sleep( 1 );
		err( sprintf( "Found %d faults in %d files.\n", count( $faults ), count( $files ) ) );
	}
}
?>