diff --git a/core/includes/gettext.sketch.inc b/core/includes/gettext.sketch.inc new file mode 100644 index 0000000..fb73a32 --- /dev/null +++ b/core/includes/gettext.sketch.inc @@ -0,0 +1,197 @@ +supportBatch() && $destination->supportBatch() && $source->size() > $threshold) { + // Transfer data in batches. + // Built and execute batch. + $batch = gettext_transfer_batch_setup($source, $destination); + batch_set($batch); + } + else { + // Transfer all translations without interruption. + // Process the translations one by one to keep the memory footprint low. + while ($translation = $source->read()) { + $destination->write($translation); + } + } +} + +/** + * Set up a batch process to transfer Gettext data. + */ +function gettext_transfer_batch_setup($source, $destination) { + $batch = array( + 'operations' => array( + array('gettext_transfer_batch_op', array($source, $destination)), + ), + 'finished' => 'gettext_transfer_batch_finished', + ); + return $batch; +} + +/** + * Batch operation transferring gettext data. + * + * @todo Add source filter conditions (context, language(?)) as parameter. + */ +function gettext_transfer_batch_op($source, $destination) { + // Initialize sandbox for batch reading. + if (empty($context['sandbox'])) { + $context['sandbox']['transfer'] = array( + 'chunk' => min($source->chunkSize(), $destination->chunkSize()), + ); + } + + // Execute one transfer cycle, which will process bite size 'chunks'. + $success = gettext_transfer_execute($source, $destination, $context['sandbox']['transfer']); + + // After a number of cycles executing gettext_transfer_execute() the transfer + // is completed. + // See gettextapi_import_batch_op() for details. + if ($context['sandbox']['transfer']['finished']) { + $context['finished'] = 1; + + if ($success) { + // Collect statistics from $context['sandbox']['transfer'] + // and tell the world about what great work we did. + } + } +} + +/** + * Batch wrap-up for gettext data transfer. + */ +function gettext_transfer_batch_finished($success, $results, $operations) { + if ($success) { + // Call post processing handler defined by the destination object. + // Can we get the callback and arguments via $results? + $post_process_callback = $results['post_process_callback']; + $arguments = $results['post_process_arguments']; + $post_process_callback($arguments); + + // We did it, Magoo! :) + } + else { + // Sadly reporting where it went wrong. + } +} + +/** + * Post processing for gettext language import into the database. + * + * To be called after successfull (batch) import. + */ +function gettext_post_process_import($langcode) { + // After succesfull import we refresh all affected parts of the system. + _locale_invalidate_js($langcode); + cache_clear_all('locale:', 'cache', TRUE); + menu_rebuild(); +} + +/** + * Transfer gettext data from source to destination in bite size chunks. + */ +function gettext_transfer_execute($source, $destination, &$transfer) { + // If transfer is not yet started we set the header data. + // The header (if supported) is written before the first translation is written. + if (!$destination->inProgress()) { + // Transfer translation header data. + $destination->setMetaData($source->getMetaData(), $langcode); + } + + $chunk = $transfer['chunk']; + // Transfer translations as long as valid data is available and + // the bite size chunk is not yet swallowed. + while ($source->valid() && $chunk > 0) { + // Get one translation from source, write it to destination. + $translation = $source->read(); + $destination->write($translation); + $chunk--; + } + + // Report the percentage of completion for progress reporting. + // Ai, user interface. Why do we bother ;) + $transfer['state']['percentage_of_completion'] = $source->poc(); + + // Close connections when we are done and report the results. + if ($source->finished()) { + $destination->finish(); + $transfer['finished'] = TRUE; + } + + // Report statistics including errors and error log. + $transfer['result'] = $destination->statistics(); +} diff --git a/core/lib/Drupal/Core/Gettext/GettextFileInterface.php b/core/lib/Drupal/Core/Gettext/GettextFileInterface.php new file mode 100644 index 0000000..17ee38a --- /dev/null +++ b/core/lib/Drupal/Core/Gettext/GettextFileInterface.php @@ -0,0 +1,56 @@ +file_uri = $uri; + } + + /** + * Opens the file stream. + */ + function open() { + $opened_path = ''; + $this->stream = new PublicStream(); + $this->stream->stream_open($this->file_uri, 'wb', STREAM_REPORT_ERRORS, $opened_path); + // @todo handle errors. + } + + /** + * Closes the file stream. + */ + function close() { + $this->stream->stream_close(); + } + + /** + * Reads single string from the file. + */ + function read() { + return $this->stream->stream_read(1); + } + + /** + * Writes strings to the file. + */ + function write($data) { + $this->stream->stream_write($data); + } + +} \ No newline at end of file diff --git a/core/lib/Drupal/Core/Gettext/GettextInterface.php b/core/lib/Drupal/Core/Gettext/GettextInterface.php new file mode 100644 index 0000000..c2872a8 --- /dev/null +++ b/core/lib/Drupal/Core/Gettext/GettextInterface.php @@ -0,0 +1,48 @@ +gettextInterface = $interface; + } + + /** + * Implements magic function __destruct(). + */ + public function __destruct() { + $this->gettextInterface->close(); + } + + /** + * Return a translation object (singular or plural) + * + * @todo Define a translation object for this purpose? + * Or use a standard class for better performance? + */ + public function read() { + // If we have not read any data before, we open the connection, + // read the PO header and set the flag that we are in progress. + if (!$this->inProgress) { + $this->gettextInterface->open(); + // @todo Handle initialisation error. + $this->sourceSize = $this->gettextInterface->size(); + // Read the PO header and store the data. + $this->readHeader(); + // @todo Handle read errors. + $this->inProgress = TRUE; + } + + $translation = $this->readTranslation($this->filter); + // @todo Handle read errors. + $this->valid = TRUE; + $this->index++; + + // @todo detect EOF + if ($eof) { + $this->finished = TRUE; + } + + return $translation; + } + + private function readTranslation($filter) { + $string = $this->gettextInterface->read(); + } + + private function readHeader() { + $string = $this->gettextInterface->read(); + } + + /** + * Parses the PO header and stores it as meta data. + */ + public function parseHeader() { + $this->MetaData = array(); + } + + private function readLine() { + /* + * The parser context. Can be: + * - 'COMMENT' (#) + * - 'MSGID' (msgid) + * - 'MSGID_PLURAL' (msgid_plural) + * - 'MSGCTXT' (msgctxt) + * - 'MSGSTR' (msgstr or msgstr[]) + * - 'MSGSTR_ARR' (msgstr_arg) + */ + $this->context = 'COMMENT'; + + // Current entry being read. + $current = array(); + + // Current plurality for 'msgstr[]'. + $plural = 0; + + // Current line. + $this->lineno = 0; + + // Read one line from the interface. + $line = $this->gettextInterface->read(); + + if (!$this->finished) { + + if ($this->lineno == 0) { + // The first line might come with a UTF-8 BOM, which should be removed. + $line = str_replace("\xEF\xBB\xBF", '', $line); + } + + $this->lineno++; + + // Trim away the linefeed. + $line = trim(strtr($line, array("\\\n" => ""))); + + if (!strncmp('#', $line, 1)) { + // Lines starting with '#' are comments. + + if ($this->context == 'COMMENT') { + // Already in comment token, insert the comment. + $current['#'][] = substr($line, 1); + } + elseif (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { + // We are currently in string token, close it out. + $this->saveOneString($current); + + // Start a new entry for the comment. + $current = array(); + $current['#'][] = substr($line, 1); + + $this->context = 'COMMENT'; + } + else { + // A comment following any other token is a syntax error. + $this->log('The translation file %filename contains an error: "msgstr" was expected but not found on line %line.', $this->lineno); + return FALSE; + } + } + elseif (!strncmp('msgid_plural', $line, 12)) { + // A plural form for the current message. + + if ($this->context != 'MSGID') { + // A plural form cannot be added to anything else but the id directly. + $this->log('The translation file %filename contains an error: "msgid_plural" was expected but not found on line %line.', $this->lineno); + return FALSE; + } + + // Remove 'msgid_plural' and trim away whitespace. + $line = trim(substr($line, 12)); + // At this point, $line should now contain only the plural form. + + $quoted = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The plural form must be wrapped in quotes. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + // Append the plural form to the current entry. + if (is_string($current['msgid'])) { + // The first value was stored as string. Now we know the context is + // plural, it is converted to array. + $current['msgid'] = array($current['msgid']); + } + $current['msgid'][] = $quoted; + + $this->context = 'MSGID_PLURAL'; + } + elseif (!strncmp('msgid', $line, 5)) { + // Starting a new message. + + if (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { + // We are currently in a message string, close it out. + $this->saveOneString($current); + + // Start a new context for the id. + $current = array(); + } + elseif ($this->context == 'MSGID') { + // We are currently already in the context, meaning we passed an id with no data. + $this->log('The translation file %filename contains an error: "msgid" is unexpected on line %line.', $this->lineno); + return FALSE; + } + + // Remove 'msgid' and trim away whitespace. + $line = trim(substr($line, 5)); + // At this point, $line should now contain only the message id. + + $quoted = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The message id must be wrapped in quotes. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + $current['msgid'] = $quoted; + $this->context = 'MSGID'; + } + elseif (!strncmp('msgctxt', $line, 7)) { + // Starting a new context. + + if (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { + // We are currently in a message, start a new one. + $this->saveOneString($current); + $current = array(); + } + elseif (!empty($current['msgctxt'])) { + // A context cannot apply to another context. + $this->log('The translation file %filename contains an error: "msgctxt" is unexpected on line %line.', $this->lineno); + return FALSE; + } + + // Remove 'msgctxt' and trim away whitespaces. + $line = trim(substr($line, 7)); + // At this point, $line should now contain the context. + + $quoted = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The context string must be quoted. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + $current['msgctxt'] = $quoted; + + $this->context = 'MSGCTXT'; + } + elseif (!strncmp('msgstr[', $line, 7)) { + // A message string for a specific plurality. + + if (($this->context != 'MSGID') && ($this->context != 'MSGCTXT') && ($this->context != 'MSGID_PLURAL') && ($this->context != 'MSGSTR_ARR')) { + // Message strings must come after msgid, msgxtxt, msgid_plural, or other msgstr[] entries. + $this->log('The translation file %filename contains an error: "msgstr[]" is unexpected on line %line.', $this->lineno); + return FALSE; + } + + // Ensure the plurality is terminated. + if (strpos($line, ']') === FALSE) { + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + // Extract the plurality. + $frombracket = strstr($line, '['); + $plural = substr($frombracket, 1, strpos($frombracket, ']') - 1); + + // Skip to the next whitespace and trim away any further whitespace, bringing $line to the message data. + $line = trim(strstr($line, " ")); + + $quoted = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The string must be quoted. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + $current['msgstr'][$plural] = $quoted; + + $this->context = 'MSGSTR_ARR'; + } + elseif (!strncmp("msgstr", $line, 6)) { + // A string for the an id or context. + + if (($this->context != 'MSGID') && ($this->context != 'MSGCTXT')) { + // Strings are only valid within an id or context scope. + $this->log('The translation file %filename contains an error: "msgstr" is unexpected on line %line.', $this->lineno); + return FALSE; + } + + // Remove 'msgstr' and trim away away whitespaces. + $line = trim(substr($line, 6)); + // At this point, $line should now contain the message. + + $quoted = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The string must be quoted. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + $current['msgstr'] = $quoted; + + $this->context = 'MSGSTR'; + } + elseif ($line != '') { + // Anything that is not a token may be a continuation of a previous token. + + $quoted = $this->parseQuoted($line); + if ($quoted === FALSE) { + // The string must be quoted. + $this->log('The translation file %filename contains a syntax error on line %line.', $this->lineno); + return FALSE; + } + + // Append the string to the current context. + if (($this->context == 'MSGID') || ($this->context == 'MSGID_PLURAL')) { + $current['msgid'] .= $quoted; + } + elseif ($this->context == 'MSGCTXT') { + $current['msgctxt'] .= $quoted; + } + elseif ($this->context == 'MSGSTR') { + $current['msgstr'] .= $quoted; + } + elseif ($this->context == 'MSGSTR_ARR') { + $current['msgstr'][$plural] .= $quoted; + } + else { + // No valid context to append to. + $this->log('The translation file %filename contains an error: there is an unexpected string on line %line.', $this->lineno); + return FALSE; + } + } + } + + // End of PO file, closed out the last entry. + if (($this->context == 'MSGSTR') || ($this->context == 'MSGSTR_ARR')) { + $this->saveOneString($current); + } + elseif ($this->context != 'COMMENT') { + $this->log('The translation file %filename ended unexpectedly at line %line.', $this->lineno); + return FALSE; + } + } + + /** + * Sets an error message if an error occurred during locale file parsing. + * + * @param $message + * The message to be translated. + * @param $lineno + * An optional line number argument. + */ + protected function log($message, $lineno = NULL) { + if (isset($lineno)) { + $vars['%line'] = $lineno; + } + $t = get_t(); + $this->errorLog[] = $t($message, $vars); + } + + /** + * Store the parsed values as translation object. + */ + public function saveOneString($value) { + $plural = FALSE; + + $comments = empty($value['#']) ? $this->shortenComments($value['#']) : ''; + + if (is_array($value['msgstr'])) { + // Sort plural variants by their form index. + ksort($value['msgstr']); + $plural = TRUE; + } + + $translation = stdClass(); + $translation->context = isset($value['msgctxt']) ? $value['msgctxt'] : ''; + $translation->source = $value['msgid']; + $translation->translation = $value['msgstr']; + $translation->plural = $plural; + $translation->comment = $comments; + } + + /** + * Parses a string in quotes. + * + * @param $string + * A string specified with enclosing quotes. + * + * @return + * The string parsed from inside the quotes. + */ + function parseQuoted($string) { + if (substr($string, 0, 1) != substr($string, -1, 1)) { + return FALSE; // Start and end quotes must be the same + } + $quote = substr($string, 0, 1); + $string = substr($string, 1, -1); + if ($quote == '"') { // Double quotes: strip slashes + return stripcslashes($string); + } + elseif ($quote == "'") { // Simple quote: return as-is + return $string; + } + else { + return FALSE; // Unrecognized quote + } + } + + /** + * Generates a short, one-string version of the passed comment array. + * + * @param $comment + * An array of strings containing a comment. + * + * @return + * Short one-string version of the comment. + */ + private function shortenComments($comments) { + $comm = ''; + while (count($comment)) { + $test = $comm . substr(array_shift($comment), 1) . ', '; + if (strlen($comm) < 130) { + $comm = $test; + } + else { + break; + } + } + return trim(substr($comm, 0, -2)); + } + +} diff --git a/core/lib/Drupal/Core/Gettext/PoWriter.php b/core/lib/Drupal/Core/Gettext/PoWriter.php new file mode 100644 index 0000000..427e585 --- /dev/null +++ b/core/lib/Drupal/Core/Gettext/PoWriter.php @@ -0,0 +1,161 @@ +inProgress) { + $this->gettextInterface->open(); + // @todo Handle initialisation error. + $this->gettextInterface->write($this->compileHeader()); + // @todo Handle write errors. + $this->inProgress = TRUE; + } + + $this->gettextInterface->write($this->compileTranslation($translation), $this->writeMode); + // @todo Handle write errors. + } + + /** + * Compile PO translations strings from a translation object. + * + * Translation object consists of: + * source string (singular) or array of strings (plural) + * translation string (singular) or array of strings (plural) + * plural TRUE: source and translation are plurals + * context source context string + */ + private function compileTranslation($translation) { + $output = ''; + + // Format string context. + if (!empty($translation->context)) { + $output .= 'msgctxt ' . $this->formatString($translation->context); + } + + // Format translation + if ($translation->plural) { + $output .= $this->formatPlural($translation); + } + else { + $output .= $this->formatSingular($translation); + } + + // Add one empty line to separate the translations. + + return $output; + } + + /** + * Compile the PO header. + */ + private function compileHeader() { + $output = ''; + + // Add language description and author as comment. + $languages = language_list(); + $language_name = isset($languages[$this->langcode]) ? $languages[$this->langcode]->name : ''; + $output .= '# ' . $language_name . ' translation of ' . variable_get('site_name', 'Drupal') . "\n"; + if (!empty($this->metaData['authors'])) { + $output .= '# Generated by ' . implode("\n# ", $this->metaData['authors']) . "\n"; + } + $output .= "#\n"; + + // Add the actual header information. + $output .= "msgid \"\"\n"; + $output .= "msgstr \"\"\n"; + $output .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n"; + $output .= "\"POT-Creation-Date: " . $this->metaData['po_date'] . "\\n\"\n"; + $output .= "\"PO-Revision-Date: " . $this->metaData['po_date'] . "\\n\"\n"; + $output .= "\"Last-Translator: NAME \\n\"\n"; + $output .= "\"Language-Team: LANGUAGE \\n\"\n"; + $output .= "\"MIME-Version: 1.0\\n\"\n"; + $output .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n"; + $output .= "\"Content-Transfer-Encoding: 8bit\\n\"\n"; + $output .= "\"Plural-Forms: " . $this->metaData['plurals'] . "\\n\"\n"; + $output .= "\n"; + + return $output; + } + + /** + * Formats a plural translation. + */ + private function formatPlural($translation) { + $output = ''; + + // Format source strings. + $output .= 'msgid ' . $this->formatString($translation->source[0]); + $output .= 'msgid_plural ' . $this->formatString($translation->source[1]); + + // Format translation strings. + $plurals = variable_get('locale_translation_plurals', array()); + // @todo What to when $plurals[$this->langcode] is not set? + // This (currently) happens if a language is created manually or importing a malformed po. + $nplurals = $plurals[$this->langcode]['plurals']; + for ($i = 0; $i < $nplurals; $i++) { + if (isset($translation->translation[$i])) { + $output .= 'msgstr[' . $i . '] ' . $this->formatString($translation->translation[$i]); + } + else { + $output .= 'msgstr[' . $i . '] ""' . "\n"; + } + } + + return $output; + } + + /** + * Formats a singular translation. + */ + private function formatSingular($translation) { + $output = ''; + $output .= 'msgid ' . $this->formatString($translation->source); + $output .= 'msgstr ' . $this->formatString($translation->translation); + return $output; + } + + + /** + * Formats a string for output on multiple lines. + */ + private function formatString($string) { + // Escape characters for processing. + $string = addcslashes($string, "\0..\37\\\""); + + // Always include a line break after the explicit \n line breaks from + // the source string. Otherwise wrap at 70 chars to accommodate the extra + // format overhead too. + $parts = explode("\n", wordwrap(str_replace('\n', "\\n\n", $string), 70," \n")); + + // Multiline string should be exported starting with a "" and newline to + // have all lines aligned on the same column. + if (count($parts) > 1) { + return "\"\"\n\"" . implode("\"\n\"", $parts) . "\"\n"; + } + // Single line strings are output on the same line. + else { + return "\"$parts[0]\"\n"; + } + } + +} diff --git a/core/lib/Drupal/Core/Gettext/Reader.php b/core/lib/Drupal/Core/Gettext/Reader.php new file mode 100644 index 0000000..93f441f --- /dev/null +++ b/core/lib/Drupal/Core/Gettext/Reader.php @@ -0,0 +1,155 @@ +gettextInterface = $interface; + } + + /** + * Implements magic function __destruct(). + */ + public function __destruct() { + $this->gettextInterface->close(); + } + + /** + * Return a translation object (singular or plural) + * + * @todo Define a translation object for this purpose? + * Or use a standard class for better performance? + */ + public function read() { + } + + /** + * Return header/meta data (date, plural formula, etc.) + */ + public function getMetaData() { + return $this->metaData; + } + + /** + * Return TRUE if the file is opened or the transfer has started. + */ + public function inProgress() { + return $this->inProgress; + } + + /** + * Return the name of an error callback function + */ + public function errorCallback() { + return ''; + } + + /** + * Return arguments for an error callback function + */ + public function errorArguments() { + return array(); + } + + /** + * Return the name of a post processing callback function + */ + public function postProcessCallback() { + return ''; + } + + /** + * Return arguments for a post processing callback function + */ + public function postProcessArguments() { + return array(); + } + + /** + * Return the calculated or estimated size in number of translations. Zero for unknown. To be generated without opening the connection. e.g. use file size not number of lines. + */ + public function size() { + return $this->sourceSize; + } + + /** + * Return a bite size. Based on experience and size to be transfered within a reasonable time. Size in number of source/translations pairs. e.g. database records in locales_source or locales_target tables. A set of plural's are counted as one. + */ + public function biteSize() { + return $this->bite_size; + } + + /** + * Accept a set of data filter arguments: Language, Context + */ + public function setFilter($arguments) { + $this->filter = $arguments; + } + + /** + * Return Is valid. Valid data is available, not EOF, no errors, etc. + */ + public function valid() { + return $this->valid; + } + + /** + * Get percentage of completion (read) + */ + public function poc() { + if (!$this->$inProgress) { + return 0; + } + if ($this->finished) { + return 1; + } + // If reading is not finished, we limit the percentage to max. 95% + // Percentages above 100% are a result of low estimate of source size and + // will be suppressed. + return min(0.95, $this->index/$this->sourceSize); + } + + /** + * Return syntax errors + */ + public function getLog($category = NULL) { + return $this->errorLog; + } + + /** + * Internal: log syntax errors + */ + protected function log($line, $message) { + $this->errorLog[$line] = $message; + } + +} diff --git a/core/lib/Drupal/Core/Gettext/Writer.php b/core/lib/Drupal/Core/Gettext/Writer.php new file mode 100644 index 0000000..bf46f8b --- /dev/null +++ b/core/lib/Drupal/Core/Gettext/Writer.php @@ -0,0 +1,170 @@ +gettextInterface = $interface; + $this->langcode = $langcode; + + $languages = language_list(); + if (isset($languages[$langcode])) { + $this->language = $languages[$langcode]; + } + else { + // @todo throw error: Unknown language code. + } + + // Set default meta data. + $this->metaData = array( + 'authors' => array(), + 'po_date' => date("Y-m-d H:iO"), + 'plurals' => 'nplurals=2; plural=(n > 1);', + ); + } + + /** + * Implements magic function __destruct(). + */ + public function __destruct() { + $this->gettextInterface->close(); + } + + /** + * Write translation string (singular or plural) + * + * @todo Define a translation object for this purpose? + * Or use a standard class for better performance? + */ + public function write($translation) { + } + + /** + * Set header/meta data (date, plural formula, etc.) + */ + public function setMetaData(array $data) { + $this->metaData = array_merge($this->metaData, $data); + } + + /** + * Return TRUE if the file is opened or the transfer has started. + */ + public function inProgress() { + return $this->inProgress; + } + + /** + * Return the name of an error callback function + */ + public function errorCallback() { + return ''; + } + + /** + * Return arguments for an error callback function + */ + public function errorArguments() { + return array(); + } + + /** + * Return the name of a post processing callback function + */ + public function postProcessCallback() { + return ''; + } + + /** + * Return arguments for a post processing callback function + */ + public function postProcessArguments() { + return array(); + } + + /** + * Return a bite size. Based on experience and size to be transfered within a reasonable time. Size in number of source/translations pairs. e.g. database records in locales_source or locales_target tables. A set of plural's are counted as one. + */ + public function biteSize() { + return $this->bite_size; + } + + /** + * Return the language code as defined by the data (e.g. po header). Use language filter (see below) as fallback. + */ + public function langcode() { + return $this->langcode; + } + + /** + * Accept write mode argument (replace, keep changes, skip existing) + * + * @todo Move this to __constructor()? + */ + public function setWriteMode($mode) { + $this->writeMode = $mode; + } + + /** + * Return Is valid. Valid data is available, not EOF, no errors, etc. + * + * @todo Needed for Writer? + */ + public function valid() { + return $this->valid; + } + + /** + * Get statistics (added, replaced, ignored, error, error log) + */ + public function statistics() { + return array( + 'added' => $this->resultsAdded, + 'replaced' => $this->resultsReplaced, + 'ignored' => $this->resultsIgnored, + 'error' => $this->resultsError, + ); + } + + /** + * Return syntax errors + */ + public function getLog($category = NULL) { + return $this->errorLog; + } + + /** + * Internal: log syntax errors + */ + protected function log($line, $message) { + $this->errorLog[$line] = $message; + } + +}