From 2c3f6d889b2f7825120cd42b019dbf9fa29b6594 Mon Sep 17 00:00:00 2001
From: =?utf8?q?Rapha=C3=ABl=20Gertz?= <git@rapsys.eu>
Date: Tue, 26 Nov 2019 15:56:25 +0100
Subject: [PATCH] Add twig classes uses Switch from Packages to
 PackageInterface Add todo about possible debug mode implementation Fix or
 extend documentation Replace some SyntaxError with RuntimeError Remove prefix
 usage, we now write to output relative to current directory or with resolved
 bundle name Retrieve output url from package getUrl method before resolving
 bundle name Better capture exception in atomic renaming section Remove
 PackNode usage and use upsteam Node, SetNode, Textnode and
 AssignNameExpression twig classes Cleanup

---
 Twig/PackTokenParser.php | 263 ++++++++++++++++++++++++++-------------
 1 file changed, 176 insertions(+), 87 deletions(-)

diff --git a/Twig/PackTokenParser.php b/Twig/PackTokenParser.php
index 54133cf..3c54a14 100644
--- a/Twig/PackTokenParser.php
+++ b/Twig/PackTokenParser.php
@@ -1,108 +1,150 @@
 <?php
-// src/Rapsys/PackBundle/Twig/PackTokenParser.php
+
 namespace Rapsys\PackBundle\Twig;
 
 use Symfony\Component\HttpKernel\Config\FileLocator;
-use Symfony\Component\Asset\Packages;
-
-class PackTokenParser extends \Twig_TokenParser {
+use Symfony\Component\Asset\PackageInterface;
+use Twig\Error\RuntimeError;
+use Twig\Error\SyntaxError;
+use Twig\Node\Expression\AssignNameExpression;
+use Twig\Node\Node;
+use Twig\Node\SetNode;
+use Twig\Node\TextNode;
+use Twig\Token;
+use Twig\TokenParser\AbstractTokenParser;
+
+class PackTokenParser extends AbstractTokenParser {
+	//The tag name
 	private $tag;
 
 	/**
-	 * Constructor.
+	 * Constructor
 	 *
-	 * @param class		$fileLocator		The FileLocator instance
-	 * @param class		$assetsPackages		The Assets Packages instance
-	 * @param string	$config			The config path
-	 * @param string	$tag			The tag name
-	 * @param string	$output			The default output string
-	 * @param array		$filters		The default filters array
+	 * @param class		$locator	The FileLocator instance
+	 * @param class		$package	The Assets Package instance
+	 * @param string	$config		The config path
+	 * @param string	$tag		The tag name
+	 * @param string	$output		The default output string
+	 * @param array		$filters	The default filters array
 	 */
-	public function __construct(FileLocator $fileLocator, Packages $assetsPackages, $config, $tag, $output, $filters) {
-		$this->fileLocator		= $fileLocator;
-		$this->assetsPackages		= $assetsPackages;
+	public function __construct(FileLocator $locator, PackageInterface $package, $config, $tag, $output, $filters) {
+		//Save locator
+		$this->locator = $locator;
 
-		//Set prefix
-		$this->prefix			= $config['prefix'];
+		//Save assets package
+		$this->package = $package;
 
 		//Set name
-		$this->name			= $config['name'];
+		$this->name = $config['name'];
 
 		//Set scheme
-		$this->scheme			= $config['scheme'];
+		$this->scheme = $config['scheme'];
 
 		//Set timeout
-		$this->timeout			= $config['timeout'];
+		$this->timeout = $config['timeout'];
 
 		//Set agent
-		$this->agent			= $config['agent'];
+		$this->agent = $config['agent'];
 
 		//Set redirect
-		$this->redirect			= $config['redirect'];
+		$this->redirect = $config['redirect'];
 
 		//Set tag
-		$this->tag			= $tag;
+		$this->tag = $tag;
 
 		//Set output
-		$this->output			= $output;
+		$this->output = $output;
 
 		//Set filters
-		$this->filters			= $filters;
+		$this->filters = $filters;
 	}
 
+	/**
+	 * Get the tag name
+	 */
 	public function getTag() {
 		return $this->tag;
 	}
 
-	public function parse(\Twig_Token $token) {
+	/**
+	 * Parse the token
+	 *
+	 * @param class $token The \Twig\Token instance
+	 *
+	 * @return class The PackNode
+	 *
+	 * @todo see if we can't add a debug mode behaviour
+	 *
+	 * If twig.debug or env=dev (or rapsys_pack.config.debug?) is set, it should be possible to loop on each input
+	 * and process the captured body without applying requested filter.
+	 *
+	 * @todo list:
+	 * - detect debug mode
+	 * - retrieve fixe link from input s%@(Name)Bundle/Resources/public(/somewhere/file.ext)%/bundles/\L\1\E\2%
+	 * - for each inputs:
+	 *   - generate a set asset_url=x
+	 *   - generate a body
+	 */
+	public function parse(Token $token) {
 		$parser = $this->parser;
 		$stream = $this->parser->getStream();
 
-		$inputs = array();
+		$inputs = [];
 		$name = $this->name;
 		$output = $this->output;
 		$filters = $this->filters;
 
 		$content = '';
 
-		while (!$stream->test(\Twig_Token::BLOCK_END_TYPE)) {
-			if ($stream->test(\Twig_Token::STRING_TYPE)) {
-				// '@jquery', 'js/src/core/*', 'js/src/extra.js'
+		//Process the token block until end
+		while (!$stream->test(Token::BLOCK_END_TYPE)) {
+			//The files to process
+			if ($stream->test(Token::STRING_TYPE)) {
+				//'somewhere/somefile.(css,img,js)' 'somewhere/*' '@jquery'
 				$inputs[] = $stream->next()->getValue();
-			} elseif ($stream->test(\Twig_Token::NAME_TYPE, 'filters')) {
-				// filter='yui_js'
+			//The filters token
+			} elseif ($stream->test(Token::NAME_TYPE, 'filters')) {
+				//filter='yui_js'
 				$stream->next();
-				$stream->expect(\Twig_Token::OPERATOR_TYPE, '=');
-				$filters = array_merge($filters, array_filter(array_map('trim', explode(',', $stream->expect(\Twig_Token::STRING_TYPE)->getValue()))));
-			} elseif ($stream->test(\Twig_Token::NAME_TYPE, 'output')) {
-				// output='js/packed/*.js' OR output='js/core.js'
+				$stream->expect(Token::OPERATOR_TYPE, '=');
+				$filters = array_merge($filters, array_filter(array_map('trim', explode(',', $stream->expect(Token::STRING_TYPE)->getValue()))));
+			//The output token
+			} elseif ($stream->test(Token::NAME_TYPE, 'output')) {
+				//output='js/packed/*.js' OR output='js/core.js'
 				$stream->next();
-				$stream->expect(\Twig_Token::OPERATOR_TYPE, '=');
-				$output = $stream->expect(\Twig_Token::STRING_TYPE)->getValue();
-			} elseif ($stream->test(\Twig_Token::NAME_TYPE, 'name')) {
-				// name='core_js'
+				$stream->expect(Token::OPERATOR_TYPE, '=');
+				$output = $stream->expect(Token::STRING_TYPE)->getValue();
+			//The name token
+			} elseif ($stream->test(Token::NAME_TYPE, 'name')) {
+				//name='core_js'
 				$stream->next();
-				$stream->expect(\Twig_Token::OPERATOR_TYPE, '=');
-				$name = $stream->expect(\Twig_Token::STRING_TYPE)->getValue();
+				$stream->expect(Token::OPERATOR_TYPE, '=');
+				$name = $stream->expect(Token::STRING_TYPE)->getValue();
+			//Unexpected token
 			} else {
 				$token = $stream->getCurrent();
-				throw new \Twig_Error_Syntax(sprintf('Unexpected token "%s" of value "%s"', \Twig_Token::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $stream->getSourceContext());
+				throw new SyntaxError(sprintf('Unexpected token "%s" of value "%s"', Token::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $stream->getSourceContext());
 			}
 		}
 
-		$stream->expect(\Twig_Token::BLOCK_END_TYPE);
+		//Process end block
+		$stream->expect(Token::BLOCK_END_TYPE);
+
+		//Process body
+		$body = $this->parser->subparse([$this, 'testEndTag'], true);
 
-		$body = $this->parser->subparse(array($this, 'testEndTag'), true);
+		//Process end block
+		$stream->expect(Token::BLOCK_END_TYPE);
 
-		$stream->expect(\Twig_Token::BLOCK_END_TYPE);
+		//TODO: debug mode should be inserted here before the output variable is rewritten
 
 		//Replace star with sha1
 		if (($pos = strpos($output, '*')) !== false) {
-			#XXX: assetic code: substr(sha1(serialize($inputs).serialize($filters).serialize($options)), 0, 7)
+			//XXX: assetic use substr(sha1(serialize($inputs).serialize($filters).serialize($options)), 0, 7)
 			$output = substr($output, 0, $pos).sha1(serialize($inputs).serialize($filters)).substr($output, $pos + 1);
 		}
 
-		//Deal with inputs
+		//Process inputs
 		for($k = 0; $k < count($inputs); $k++) {
 			//Deal with generic url
 			if (strpos($inputs[$k], '//') === 0) {
@@ -112,18 +154,20 @@ class PackTokenParser extends \Twig_TokenParser {
 			} elseif (strpos($inputs[$k], '://') === false) {
 				//Check if we have a bundle path
 				if ($inputs[$k][0] == '@') {
-					//Check that we don't have only a path
+					//Check that we have a / separator between bundle name and path
 					if (($pos = strpos($inputs[$k], '/')) === false) {
-						#TODO: @jquery support (if we really want it)
-						#header('Content-Type: text/plain');
-						#var_dump($inputs);
-						#if ($inputs[0] == '@jquery') {
-						#	exit;
-						#}
-						throw new \Twig_Error_Syntax(sprintf('Invalid input path "%s"', $inputs[$k]), $token->getLine(), $stream->getSourceContext());
+						//TODO: add a @jquery magic feature ?
+						/*
+						header('Content-Type: text/plain');
+						var_dump($inputs);
+						if ($inputs[0] == '@jquery') {
+							exit;
+						}
+						*/
+						throw new RuntimeError(sprintf('Invalid input path "%s"', $inputs[$k]), $token->getLine(), $stream->getSourceContext());
 					}
 					//Resolve bundle prefix
-					$inputs[$k] = $this->fileLocator->locate(substr($inputs[$k], 0, $pos)).substr($inputs[$k], $pos + 1);
+					$inputs[$k] = $this->locator->locate(substr($inputs[$k], 0, $pos)).substr($inputs[$k], $pos + 1);
 				}
 				//Deal with globs
 				if (strpos($inputs[$k], '*') !== false || (($a = strpos($inputs[$k], '{')) !== false && ($b = strpos($inputs[$k], ',', $a)) !== false && strpos($inputs[$k], '}', $b) !== false)) {
@@ -131,8 +175,9 @@ class PackTokenParser extends \Twig_TokenParser {
 					$replacement = glob($inputs[$k], GLOB_NOSORT|GLOB_BRACE);
 					//Check that these are working files
 					foreach($replacement as $input) {
+						//Check that it's a file
 						if (!is_file($input)) {
-							throw new \Twig_Error_Syntax(sprintf('Input path "%s" from "%s" is not a file', $input, $inputs[$k]), $token->getLine(), $stream->getSourceContext());
+							throw new RuntimeError(sprintf('Input path "%s" from "%s" is not a file', $input, $inputs[$k]), $token->getLine(), $stream->getSourceContext());
 						}
 					}
 					//Replace with glob path
@@ -141,20 +186,20 @@ class PackTokenParser extends \Twig_TokenParser {
 					$k += count($replacement) - 1;
 				//Check that it's a file
 				} elseif (!is_file($inputs[$k])) {
-					throw new \Twig_Error_Syntax(sprintf('Input path "%s" is not a file', $inputs[$k]), $token->getLine(), $stream->getSourceContext());
+					throw new RuntimeError(sprintf('Input path "%s" is not a file', $inputs[$k]), $token->getLine(), $stream->getSourceContext());
 				}
 			}
 		}
 
 		//Init context
 		$ctx = stream_context_create(
-			array(
-				'http' => array(
+			[
+				'http' => [
 					'timeout' => $this->timeout,
 					'user_agent' => $this->agent,
 					'redirect' => $this->redirect,
-				)
-			)
+				]
+			]
 		);
 
 		//Check inputs
@@ -163,13 +208,15 @@ class PackTokenParser extends \Twig_TokenParser {
 			foreach($inputs as $input) {
 				//Try to retrieve content
 				if (($data = file_get_contents($input, false, $ctx)) === false) {
-					throw new \Twig_Error_Syntax(sprintf('Unable to retrieve input path "%s"', $input), $token->getLine(), $stream->getSourceContext());
+					throw new RuntimeError(sprintf('Unable to retrieve input path "%s"', $input), $token->getLine(), $stream->getSourceContext());
 				}
 				//Append content
 				$content .= $data;
 			}
 		} else {
-			#TODO: trigger error about empty inputs ?
+			//Trigger error about empty inputs
+			//XXX: There may be a legitimate case where inputs is empty, if so please contact the author
+			throw new RuntimeError('Empty inputs token', $token->getLine(), $stream->getSourceContext());
 		}
 
 		//Check filters
@@ -177,7 +224,7 @@ class PackTokenParser extends \Twig_TokenParser {
 			//Apply all filters
 			foreach($filters as $filter) {
 				//Init args
-				$args = array($stream->getSourceContext(), $token->getLine());
+				$args = [$stream->getSourceContext(), $token->getLine()];
 				//Check if args is available
 				if (!empty($filter['args'])) {
 					//Append args if provided
@@ -193,48 +240,90 @@ class PackTokenParser extends \Twig_TokenParser {
 				unset($tool, $reflection);
 			}
 		} else {
-			#TODO: trigger error about empty filters ?
+			//Trigger error about empty filters
+			//XXX: There may be a legitimate case where filters is empty, if so please contact the author
+			throw new RuntimeError('Empty filters token', $token->getLine(), $stream->getSourceContext());
+		}
+
+		//Retrieve asset uri
+		//XXX: this path is the merge of services.assets.path_package.arguments[0] and rapsys_pack.output.(css,img,js)
+		if (($outputUrl = $this->package->getUrl($output)) === false) {
+			throw new RuntimeError(sprintf('Unable to get url for asset: %s', $output), $token->getLine(), $stream->getSourceContext());
 		}
 
-		//Create output dir on demand
-		if (!is_dir($parent = $dir = dirname($this->prefix.$output))) {
+		//Check if we have a bundle path
+		if ($output[0] == '@') {
+			//Check that we have a / separator between bundle name and path
+			if (($pos = strpos($output, '/')) === false) {
+				throw new RuntimeError(sprintf('Invalid output path "%s"', $output), $token->getLine(), $stream->getSourceContext());
+			}
+			//Resolve bundle prefix
+			$output = $this->locator->locate(substr($output, 0, $pos)).substr($output, $pos + 1);
+		}
+
+		//Create output dir if not present
+		if (!is_dir($dir = dirname($output))) {
 			try {
 				//XXX: set as 0777, symfony umask (0022) will reduce rights (0755)
 				mkdir($dir, 0777, true);
 			} catch (\Exception $e) {
-				throw new \Twig_Error_Syntax(sprintf('Unable to create directory: %s', $dir), $token->getLine(), $stream->getSourceContext());
+				throw new RuntimeError(sprintf('Output directory "%s" do not exists and unable to create it', $dir), $token->getLine(), $stream->getSourceContext());
 			}
 		}
 
 		//Send file content
-		//XXX: atomic rotation is required to avoid partial content in reverse cache
-		if (file_put_contents($this->prefix.$output.'.new', $content) === false) {
-			throw new \Twig_Error_Syntax(sprintf('Unable to write to: %s', $prefix.$output.'.new'), $token->getLine(), $stream->getSourceContext());
+		//XXX: to avoid partial content in reverse cache we use atomic rotation write, unlink and move
+		try {
+			if (file_put_contents($output.'.new', $content) === false) {
+				throw new \Exception();
+			}
+		} catch(\Exception $e) {
+			throw new RuntimeError(sprintf('Unable to write to: %s', $output.'.new'), $token->getLine(), $stream->getSourceContext());
 		}
 
 		//Remove old file
-		if (is_file($this->prefix.$output) && unlink($this->prefix.$output) === false) {
-			throw new \Twig_Error_Syntax(sprintf('Unable to unlink: %s', $prefix.$output), $token->getLine(), $stream->getSourceContext());
+		if (is_file($output)) {
+			try {
+				if (unlink($output) === false) {
+					throw new \Exception();
+				}
+			} catch (\Exception $e) {
+				throw new RuntimeError(sprintf('Unable to unlink: %s', $output), $token->getLine(), $stream->getSourceContext());
+			}
 		}
 
 		//Rename it
-		if (rename($this->prefix.$output.'.new', $this->prefix.$output) === false) {
-			throw new \Twig_Error_Syntax(sprintf('Unable to rename: %s to %s', $prefix.$output.'.new', $prefix.$output), $token->getLine(), $stream->getSourceContext());
+		try {
+			if (rename($output.'.new', $output) === false) {
+					throw new \Exception();
+			}
+		} catch (\Exception $e) {
+			throw new RuntimeError(sprintf('Unable to rename: %s to %s', $output.'.new', $output), $token->getLine(), $stream->getSourceContext());
 		}
 
-		//Retrieve asset uri
-		//XXX: was next line to support module specific asset configuration
-		#if (($output = $this->assetsPackages->getUrl($output, 'rapsys_pack')) === false) {
-		if (($output = $this->assetsPackages->getUrl($output)) === false) {
-			#throw new \Twig_Error_Syntax(sprintf('Unable to get url for asset: %s with package %s', $output, 'rapsys_pack'), $token->getLine(), $stream->getSourceContext());
-			throw new \Twig_Error_Syntax(sprintf('Unable to get url for asset: %s', $output), $token->getLine(), $stream->getSourceContext());
-		}
+		//Set name in context key
+		$ref = new AssignNameExpression($name, $token->getLine());
+
+		//Set output in context value
+		$value = new TextNode($outputUrl, $token->getLine());
 
-		//Send pack node
-		return new PackNode(array('value' => $body), array('inputs' => $inputs, 'filters' => $filters, 'name' => $name, 'output' => $output), $token->getLine(), $this->getTag());
+		//Send body with context set
+		return new Node([
+			//This define name in twig template by prepending $context['<name>'] = '<output>';
+			new SetNode(true, $ref, $value, $token->getLine(), $this->getTag()),
+			//The tag captured body
+			$body
+		]);
 	}
 
-	public function testEndTag(\Twig_Token $token) {
-		return $token->test(array('end'.$this->getTag()));
+	/**
+	 * Test for tag end
+	 *
+	 * @param class $token The \Twig\Token instance
+	 *
+	 * @return bool
+	 */
+	public function testEndTag(Token $token) {
+		return $token->test(['end'.$this->getTag()]);
 	}
 }
-- 
2.41.3