Zajímavá PHP úloha

Zajímavá PHP úloha Včera mi mailem přišla zajímavá poptávka. Zda bych se chtěl jako php programátor účastnit jednoho většího projektu ve frameworku Kohana. Ač jsem v Kohaně nic neudělal, na poptávku jsem odpověděl a vyměnil jsem si s klientem pár e-mailů. Přece jen nikdo nemůže znát všechny frameworky pod sluncem. Pořád je třeba se učit. A já si věřím ;)

V jednom z mailů padla otázka, zda by mohli mé znalosti vyzkoušet na jednoduché úloze. Řešení by mi nemělo zabrat více jak 4 hodiny času.

Odpovídám tedy, proč ne.

Obratem mi přišlo zadání v angličtině (níže přeložím).

Create a PHP5 class to collect i18n strings

Situation:
Imagine we have a PHP application that includes i18n strings throughout the code. We need to collect them to be able to prepare translations.

Examples:
<?php echo __('Hello World'); ?>
<?php echo __('Hello :username', array(':username' => 'Frank')); ?>
<?php echo '<h1>'.__('Hello d\'Agostino').'</h1>'; ?>

Specific tasks:
1. Create PHP5 class
2. Prepate a method that will output all i18n strings (such as Hello World from the first example) in a CSV file (UTF-8, saved automatically to application directory root)
3. The .csv has to have 4 columns: A) string itself, B) file name and path (relative to application directory), C) row number in the file, D) column number in the file
4. Please, try to seperate the logic, so that output method could be easily changed (ie. from saving to a CSV file to HTML dump)
5. Think about configuration, where and how will these variables be set: application directory, CSV filename, CSV delimiter, CSV enclosure
6. When the method to collect strings and output the CSV file is called, you should return total number of strings found in case of success

Notes:
Don't forget that strings might include entities, HTML elements, escaped single quotes or various strange characters
Strings are always enclosed by single quotes and they never include PHP variables directly (variables are bound by using colons)
Don't worry about some string showing up repeatedly, you have to include all occurencies along with filenames and caret position details
You are strongly encouraged to use coding standards of Kohana Framework 3.1 [http://kohanaframework.org/3.1/guide/kohana/conventions]

Ve zkratce: mám vytvořit php třídu, která vyparsuje z php souborů i18n řetězce pro překlad a uloží je do .csv souboru. Znáte .po/.mo soubory a gettext, že? Tak něco takového :)

Problém veskrze zajímavý. Hlavně ta poslední část, kde se píše o tom, jaké zvěrstva mohou řetězce obsahovat.

Téměř ihned jsem zavrhnul klasické parsování skrze regulární výrazy, protože bych dost těžko odhaloval konec řetězce, nemluvím o získání pozice, tedy řádku a sloupce.

Protože jsem nedávno experimentoval s analýzou php souborů, napadlo mě, že bych mohl využít tokenizér.

Ten je v php reprezentován funkcí token_get_all(), která vrací pole tokenů, kde je každý token reprezentován buď jako string nebo jako tří prvkové pole (token index, obsah tokenu, řádek v souboru).

Například výstupem pro tento php skript:

                <?php echo __('Hello World'); ?>
                

bude toto pole:

                Array
                (
                    [0] => Array
                        (
                            [0] => 368
                            [1] => <?php
                            [2] => 1
                        )

                    [1] => Array
                        (
                            [0] => 316
                            [1] => echo
                            [2] => 2
                        )

                    [2] => Array
                        (
                            [0] => 371
                            [1] =>  
                            [2] => 2
                        )

                    [3] => Array
                        (
                            [0] => 307
                            [1] => __
                            [2] => 2
                        )

                    [4] => (
                    [5] => Array
                        (
                            [0] => 315
                            [1] => 'Hello World'
                            [2] => 2
                        )

                    [6] => )
                    [7] => ;
                    [8] => Array
                        (
                            [0] => 371
                            [1] => 

                            [2] => 2
                        )

                    [9] => Array
                        (
                            [0] => 370
                            [1] => ?>
                            [2] => 3
                        )
                )
                

Z výpisu je jasné, že řetězec, který hledáme je v poli [5]. V prvku [3] máme podtržítka, které značí začátek funkce.

Teď nám tedy stačí najít v poli podtržítka, otestovat následující závorku a o prvek dále máme řetězec.

Indexy 307 a 315 si pomocí token_name() převedem na symbolické názvy T_STRING a T_CONSTANT_ENCAPSED_STRING. Ty budeme v kódu testovat.

Vytvoříme si tedy základní konstrukci třídy, která by mohla vypadat třeba takto.

                class Parser
                {
                    private $phpfile;
                    private $phptokens;
                    private $phpcontent;
                    
                    private function Load()
                    {
                    }

                    private function Tokenize()
                    {
                    }

                    public function Parse ( $phpfile )
                    {
                       // load
                       // tokenize
                       // parse
                       // return array (filename, text, row, col)
                    }
                }
                

Nahrání obsahu je jednoduché. Použijeme php funkci file, která nám vrátí obsah souboru jako pole, což se nám bude hodit později při určování čísla sloupce.

                private function Load()
                {
                    $this->phpcontent = @file($this->phpfile);
                    if ($this->phpcontent===false)
                    {
                        throw new Exception('Load file failed.');
                    }                    
                }
                

Tokenizování obsahu je taky jednoduché.

                private function Tokenize()
                {
                    $this->phptokens = token_get_all(
                        join('', $this->phpcontent)
                    );
                }
                

A teď k parsování. Nejprve musíme najít podtržítka. Vytvoříme si tedy metodu, která nám vrátí pole s indexy podtržítek.

                private function GetStartTokens()
                {
                    $result = array();
                    foreach($this->phptokens as $index=>$token)
                    {
                        if (!is_array($token)) {
                            continue;
                        }
                        if ('T_STRING'==token_name($token[0]) &&
                                $token[1]=='__')
                        {
                            $result[] = $index;
                        }
                    }
                    return $result;
                }                    
                

Nyní projdeme pole indexů a otestujeme, zda je další prvek závorka a pokud ano, najdeme řetězec, získáme číslo řádku a sloupce.

                // zde bude vysledek
                $result = array();

                // pole s indexy podtrzitek
                $tokens = $this->GetStartTokens();
                foreach(tokens as $index)
                {
                    // testujeme zda je dalsi prvek zavorka
                    if ('('==$this->phptokens[$index+1])
                    {
                        // retez si ulozime do promenne
                        // abychom pozdeji mohli najit cislo sloupce
                        $search = '__(';

                        // retezec nemusi nasledovat ihned za zavorkou
                        // muzou tam byt mezery
                        // TODO: tady to snese jedno male vylepseni
                        for($i=$index+2; $i<$index+4; $i++)
                        {
                            if (is_array($this->phptokens[$i]))
                            {
                                $search .= $this->phptokens[$i][1];
                            }
                            else
                            {
                                $search .= $this->phptokens[$i];
                            }

                            // test i18n retezce
                            if (is_array($this->phptokens[$i]) &&
                                    'T_CONSTANT_ENCAPSED_STRING'==token_name($this->phptokens[$i][0]))
                            {
                                $cols = array();

                                // hledani sloupce
                                // vyuzijeme sikovny parametr PREG_OFFSET_CAPTURE
                                // tady se nam hodi nacitani souboru skrze file
                                if (preg_match_all('/'.preg_quote($search).'/',
                                    $this->phpcontent[$this->phptokens[$i][2]-1],
                                    $matches,
                                    PREG_OFFSET_CAPTURE))
                                {
                                    foreach($matches[0] as $match)
                                    {
                                        $cols[] = $match[1];
                                    }
                                }
                                else
                                {
                                    throw new Exception('Find column failed.');
                                }

                                // vyuzijeme hash jako index a tim odstranime duplicity
                                // vicekrat stejny retezec na stejnem radku
                                $hash = md5($this->phptokens[$i][1].'|'.($this->phptokens[$i][2]-1));

                                // vysledek ulozime do pole
                                $result[$hash] = array(
                                                     'filename' => $this->phpfile,
                                                     'string'   => $this->phptokens[$i][1],
                                                     'line'     => $this->phptokens[$i][2]-1,
                                                     'cols'     => $cols
                                );
                                break;
                            }
                        }
                    }
                }
                

Tímto máme to hlavní hotovo. Tvorbu jednoduché třídy pro ukládání výsledků CSV si nechám do dalšího článku. Mezitím to můžete zkusit sami.

Máte-li k řešení nějaké postřehy, pošlete mi je na email vč. kontaktů, rád je zveřejním.

Máte zájem o zkušeného php programátora?
Kontaktujte mě na tel. čísle (+420) 777 287 451, nebo použijte formulář níže.


Napište mi