<?php /** * Created by PhpStorm. * User: crose * Date: 3/18/16 * Time: 5:43 PM */ namespace IMATHUZH\Qfq\Core; use IMATHUZH\Qfq\Core\Helper\Support; const NESTING_TOKEN_OPEN = '#&nesting-open-&#'; const NESTING_TOKEN_CLOSE = '#&nesting-close&#'; const NESTING_TOKEN_LENGTH = 17; /** * Class BodytextParser * @package qfq */ class BodytextParser { /** * @param string $bodyText * * @return mixed|string * @throws \UserFormException */ public function process($bodyText) { $nestingOpen = ''; $nestingClose = ''; $bodyText = $this->trimAndRemoveCommentAndEmptyLine($bodyText, $nestingOpen, $nestingClose); // Encrypt double curly braces to prevent false positives with nesting: form = {{form}}\n $bodyText = Support::encryptDoubleCurlyBraces($bodyText); $bodyText = $this->joinLine($bodyText, $nestingOpen, $nestingClose); $bodyText = $this->encryptNestingDelimeter($bodyText, $nestingOpen, $nestingClose); $bodyText = $this->unNest($bodyText, $nestingOpen, $nestingClose); $bodyText = $this->trimAndRemoveCommentAndEmptyLine($bodyText, $nestingOpen, $nestingClose); $bodyText = Support::decryptDoubleCurlyBraces($bodyText); if (strpos($bodyText, NESTING_TOKEN_OPEN) !== false) { throw new \UserFormException( json_encode([ERROR_MESSAGE_TO_USER => 'Report: Missing close delimiter', ERROR_MESSAGE_TO_DEVELOPER => $bodyText]), ERROR_MISSING_CLOSE_DELIMITER); } return $bodyText; } /** * Trim all lines, remove all empty lines and all lines which start with '#' * * @param $bodytext * * @param $nestingOpen * @param $nestingClose * @return string */ private function trimAndRemoveCommentAndEmptyLine($bodytext, &$nestingOpen, &$nestingClose) { $data = array(); $src = explode(PHP_EOL, $bodytext); if ($src === false) { return ''; } $firstLine = trim($src[0]); foreach ($src as $row) { $row = trim($row); if ($row === '' || $row[0] === '#') { continue; } $data[] = $row; } $this->setNestingToken($firstLine, $nestingOpen, $nestingClose); return implode(PHP_EOL, $data); } /** * Set the 'nesting token for this tt-conten record. Valid tokens are {}, <>, [], (). * If the first line of bodytext is a comment line and the last char of that line is a valid token: set that one. * If not: set {} as nesting token. * * Example: * # Some nice text - no token found, take {} * # ] - [] * # Powefull QFQ: < - <> * * @param $firstLine * @param $nestingOpen * @param $nestingClose */ private function setNestingToken($firstLine, &$nestingOpen, &$nestingClose) { if ($nestingOpen !== '') { return; // tokens already set or not bodytext: do not change. } // Nothing defined: set default {}. if ($firstLine === false || $firstLine === '' || $firstLine[0] !== '#') { $nestingOpen = '{'; $nestingClose = '}'; return; } // Definition: first line of bodytext, has to be a comment line. If the last char is one of the valid token: set that one. // Nothing found: set {}. $nestingOpen = '{'; $nestingClose = '}'; if ($firstLine[0] === '#') { $token = substr($firstLine, -1); switch ($token) { case '<': $nestingOpen = '<'; $nestingClose = '>'; break; case '[': $nestingOpen = '['; $nestingClose = ']'; break; case '(': $nestingOpen = '('; $nestingClose = ')'; break; default: break; } } } /** * Join lines. Nesting isn't changed. * * Iterates over all lines. * Is a line a 'new line'? * no: concat it to the last one. * yes: flush the buffer, start a new 'new line' * * New Line Trigger: * a: { * b: } * c: 20 * d: 20.30 * * e: 5 { * f: 5.10 { * * g: head = * h: 10.20.head = * * c,d,e,f: ^\d+(\.\d+)*(\s*{)?$ * g,h: ^(\d+\.)*(sql|head)\s*= * * @param $bodyText * @param $nestingOpen * @param $nestingClose * @return string */ private function joinLine($bodyText, $nestingOpen, $nestingClose) { $data = array(); $bodytextArray = explode(PHP_EOL, $bodyText); $nestingOpenRegexp = $nestingOpen; if ($nestingOpen === '(' || $nestingOpen === '[') { $nestingOpenRegexp = '\\' . $nestingOpen; } $full = ''; $joinDelimiter = ' '; foreach ($bodytextArray as $row) { // Line end with '\'? if (substr($row, -1) == '\\') { $row = trim(substr($row, 0, -1)); // remove last char and trim $joinDelimiterNext = ''; } else { $joinDelimiterNext = ' '; } if (($row == $nestingOpen || $row == $nestingClose) || (1 === preg_match('/^\d+(\.\d+)*(\s*' . $nestingOpenRegexp . ')?$/', $row)) || (1 === preg_match('/^(\d+\.)*(' . TOKEN_VALID_LIST . ')\s*=/', $row)) ) { // if there is already something: save this. if ($full !== '') { $data[] = $full; } // start new line $full = $row; } else { // continue row: concat - the space is necessary to join SQL statements correctly: 'SELECT ... FROM ... WHERE ... AND\np.id=...' - here a 'AND' and 'p.id' need a space. $full .= $joinDelimiter . $row; } $joinDelimiter = $joinDelimiterNext; } // Save last line if ($full !== '') { $data[] = $full; } return implode(PHP_EOL, $data); } /** * Encrypt $nestingOpen and $nestingClose by a more complex token. This makes it easy to search later for '}' or '{' * * Valid open (complete line): {, 10 {, 10.20 { * Valid close (complete line): } * * @param $bodytext * * @param $nestingOpen * @param $nestingClose * @return mixed */ private function encryptNestingDelimeter($bodytext, $nestingOpen, $nestingClose) { if ($nestingOpen === '(' || $nestingOpen === '[') { $nestingOpen = '\\' . $nestingOpen; $nestingClose = '\\' . $nestingClose; } $bodytext = preg_replace('/^((\d+)(\.\d+)*\s*)?(' . $nestingOpen . ')$/m', '$1' . NESTING_TOKEN_OPEN, $bodytext); $bodytext = preg_replace('/^' . $nestingClose . '$/m', '$1' . NESTING_TOKEN_CLOSE, $bodytext); return $bodytext; } /** * Unnest all level. * * Input: * 10 { * sql = SELECT * 20.sql = INSERT .. * 30 { * sql = DELETE * } * } * * Output: * 10.sql = SELECT * 10.20.sql = INSERT * 10.20.30.sql = DELETE * * @param $bodytext * * @param $nestingOpen * @param $nestingClose * @return mixed|string * @throws \UserFormException */ private function unNest($bodytext, $nestingOpen, $nestingClose) { // Replace '\{' | '\}' by internal token. All remaining '}' | '{' means: 'nested' // $bodytext = str_replace('\{', '#&[_#', $bodytext); // $bodytext = str_replace('\}', '#&]_#', $bodytext); // $bodytext = Support::encryptDoubleCurlyBraces($bodytext); $result = $bodytext; $posFirstClose = strpos($result, NESTING_TOKEN_CLOSE); while ($posFirstClose !== false) { $posMatchOpen = strrpos(substr($result, 0, $posFirstClose), NESTING_TOKEN_OPEN); if ($posMatchOpen === false) { $result = $this->decryptNestingDelimeter($result, $nestingOpen, $nestingClose); throw new \UserFormException( json_encode([ERROR_MESSAGE_TO_USER => 'Missing open delimiter', ERROR_MESSAGE_TO_DEVELOPER => "Missing open delimiter: $result"]), ERROR_MISSING_OPEN_DELIMITER); } $pre = substr($result, 0, $posMatchOpen); if ($pre === false) $pre = ''; $post = substr($result, $posFirstClose + NESTING_TOKEN_LENGTH); if ($post === false) $post = ''; // trim also removes '\n' $match = trim(substr($result, $posMatchOpen + NESTING_TOKEN_LENGTH, $posFirstClose - $posMatchOpen - NESTING_TOKEN_LENGTH)); // "10.sql = SELECT...\n20 {\n $levelStartPos = strrpos(trim($pre), PHP_EOL); $levelStartPos = ($levelStartPos === false) ? 0 : $levelStartPos + 1; // Skip PHP_EOL $level = trim(substr($pre, $levelStartPos)); // if($level==='') { // $pre= // } // remove 'level' from last line $pre = substr($pre, 0, $levelStartPos); // Split nested content in single rows $lines = explode(PHP_EOL, $match); foreach ($lines as $line) { if ($line !== '') { $pre .= $level . '.' . $line . PHP_EOL; } } $result = $pre . $post; $posFirstClose = strpos($result, NESTING_TOKEN_CLOSE); } // $result = str_replace('#&[_#', '{', $result); // $result = str_replace('#&]_#', '}', $result); // $result = Support::decryptDoubleCurlyBraces($result); return $result; } /** * Decrypt complex token by '{\n' and '}\n' * * @param $bodytext * * @param $nestingOpen * @param $nestingClose * @return mixed */ private function decryptNestingDelimeter($bodytext, $nestingOpen, $nestingClose) { $bodytext = str_replace(NESTING_TOKEN_OPEN, "$nestingOpen\n", $bodytext); $bodytext = str_replace(NESTING_TOKEN_CLOSE, "$nestingClose\n", $bodytext); return $bodytext; } }