<?php

// ------------------------------------------------------------------------- //
// Analyseur Syntaxique de Requête Booléenne                                 //
// ------------------------------------------------------------------------- //
// Auteur: Théo <modelixe@phpedit.com>                                       //
// Web:    http://modelixe.phpedit.com/                                      //
// ------------------------------------------------------------------------- //

/*
**************************************************
Introduction
**************************************************

Ce script est celui qui est utilisé par le moteur de recherche associé au forum du site ModeliXe.
Je le livre tel quel, mais il est largement documenté, et peut facilement être adapté pour d'autres situations.

Pour vous aider à en appréhender son fonctionnement, je vais tout d'abord présenter ses règles d'utilisations.

**************************************************
Règles d'utilisations du moteur
**************************************************

**Définitions
Pour aider à la compréhension des propos tenus, je vais tout d'abord poser quelques définitions.

- On appel requête l'ensemble des caractères donnés en arguments dans le champ rechercher.
- On appel motif un sous ensemble d'une requête séparé par des espaces ou des guillemets.
- On appel carcatères ou expressions réservés l'ensemble des motifs ayant un sens logique pour le moteur de recherche.
- On appel opérateur l'ensemble des caractères et expressions réservés situés entre les motifs d'une requête et exprimant une relation logique entre ces motifs.
- On appel séparateur l'ensemble des caractères et expressions réservés permettant de définir des unités logiques dans la requête en cours.
- On appel motif complexe un ensemble de sous motifs encadré par le séparateur guillemet formant une unité logique.
- On appel sous-requête ou argument un ensemble de motifs d'une requête encadré par une parenthèse ouvrante et une fermante.
- On appel domaine domaine de recherche l'ensemble des champs de la table sur lesquels vont porter la sous-requête.

**La recherche booléenne
Le forum de ModeliXe est doté d'un moteur de recherche booléen, ce qui signifie que vous pouvez réaliser des recherches approfondies en utilisant un ensemble d'opérateur logique.
Les opérateurs supportés par le moteur sont :

- Le or, avec pour alias le symbole |.
- Le and, avec pour alias les symboles & ou +.
- Le not, avec pour alias les symboles - ou !.

**Les caractères et expressions réservés
Le moteur utilise une série de caractères et d'expressions spéciaux qui sont dits caractères et expressions réservés. Ils comprennent les caractères booléens, ainsi que d'autres caractères à signification particulière, en voici la liste exhaustive :

- Les booléens et leurs alias and, or, not, |, &, +, -, !.
- Les parenthèses ouvrantes et fermantes ( et ).
- Les guillemets ".
- L'arobase @.
- L'anti-slash \.

**L'échappement des caractères réservés
Pour pouvoir écrire des motifs de recherches contenant des caractères réservés, vous pouvez utiliser l'anti-slash. Le caractère suivant perdera alors son caractère réservé et prendra son sens littéral.

**Gestion des parenthèses
Le moteur accepte les expressions entre parenthèses, leur nombre est indéterminé, mais il en vérifie la bonne fermeture.

**Gestion des guillemets et motifs de recherches complexes
Le moteur accepte les expressions entre guillemets, celle-ci sont alors considérées comme des motifs de recherches entiers, les espaces et les éventuels caractères ou mots réservés (utilisés par les opérateurs booléens), ne sont pas alors considérés à l'exception bien entendu des guillemets eux-mêmes.
Pour permettre l'écriture du caractère guillemet dans les chaines entre guillemets vous pouvez utiliser le caractère d'échappement \.

**Gestion des domaines de recherche
Le moteur accepte également la gestion de domaine de recherche. Ceux-ci permettent de préciser le domaine de recherche de votre requête parmi les différentes propriétés d'un message (le sujet, l'auteur, son thème et son contenu). Le domaine de recherche peut s'appliquer à l'ensemble du motif de la recherche, mais également être précisé pour chacun de ses arguments.

Le domaine de recherche s'écrit sous la forme d'une série de lettre parmi les lettres s (sujet), a (auteur), t (thème), ou m (message), suivie d'un arobase. L'ordre des lettres est sans importance.
Si le domaine de recherche ne s'étend que sur une section de la requête, il doit être indiqué juste après l'ouverture des parenthèses.

**Règles générales
- Si un motif de recherche comprend un espace, le motif doit être entouré de guillemets.
- Chacun des motifs de recherche de la requête ne peut être inférieur à 4 caractères.
- Une requête doit contenir au moins 4 lettres, les chiffres et les caractères spéciaux ne sont pas considérés dans le décompte des caractères.
- Si la requête propose plus de 50 résultats, ceux-ci ne sont pas affichés, vous devez reformuler une requête plus précise.
- Le domaine de recherche par défaut s'étend au titre et au contenu du message.
- Si par erreur le domaine de recherche spécifié n'était pas reconnu, l'ensemble des caractères liés à la définition du domaine serait considérés comme faisant parti du motif de recherche.

**************************************************
Fonctionnement du moteur
**************************************************

L'analyseur syntaxique réalise une étude de la
requête, et renvoie un tableau associtaif contenant en index le type d'erreur rencontré et en valeur le booléen true.

la récupération de la requête SQL éventuellement générée et des messages d'erreurs éventuellement renvoyés se fait par référence.

Les messages d'erreurs sont contenus dans $Emessage, et la requête SQL dans $requete.

**************************************************
Domaine d'utilisation du moteur
**************************************************

Le moteur porte sur une table message dont le script de création et la structure est la suivante:

DROP TABLE IF EXISTS message;
CREATE TABLE message (
  mes_id int(11) NOT NULL auto_increment,
  mes_parente int(11) NOT NULL,
  mes_famille int(11) NOT NULL',
  mes_date varchar(25) NOT NULL,
  mes_auteur varchar(40) NOT NULL,
  mes_titre varchar(60) NOT NULL',
  mes_texte text NOT NULL,
  mes_email varchar(120) default NULL,
  mes_theme varchar(80) default NULL,
  mes_categorie int(4) default '0',
  mes_validite int(1) default '0',
  mes_nbfils int(4) default '0',
  mes_nbhits int(4) default '0',
  PRIMARY KEY  (mes_id)
) TYPE=MyISAM;

mes_id contient l'id du message.
mes_parente contient l'id du message parent.
mes_famille contient l'id du message original de la discussion (le parent d'origine).
mes_theme contient la définition du thème associé au message.
mes_categorie permet de gérer plusieurs catégories de forum à partir de la même table.
mes_validité est un marqueur permettant de savoir si le message est diffusable ou non.
mes_nbfils contient le nombre de fils directe du message.
mes_nbhits contient le nombre de fois que le message a été consulté.

Cette table a été en partie dénormalisée pour augmenter les performances.

**************************************************
Nota Bene
**************************************************

Le script utilise une syntaxe MySQL pour la création de la requête, et fait appel à la classe DB de PEAR.

Le script analyse lettre par lettre la requête, les lettres s,t,m, et a ont été réservées pour la définition des domaines de recherche sujet, thème, messate et auteur. Si la structure de votre table est différente et/ou que vous voulez utiliser d'autres lettres pour la définition de vos propres domaines de recherche il faudra modifier les lignes marquées d'une double étoile.

Ce script est bien sur livrée sous les conditions de la licence LGPL, bien que celle-ci ne soit pas rappelée.

*/

function rechercher($recherche, &$Emessage, &$requete){
    global
$db; // objet PEAR DB

    
$recherche = stripslashes($recherche); // requêtes du visiteur
    
$op = ''; // contient l'opérateur en cours
    
$par = 0; // contient le niveau de parenthèse en cours
    
$gui = false; // marqueur d'ouverture et de fermeture de guillemets
    
$esp = 0; // marqueur de présence d'espaces
    
$ouverture = false; // marqueur d'ouverture d'une parenthèse
    
$fermeture = false; // marqueur de fermeture d'une parenthèse
    
$fin = false; // marqueur de fin d'analyse d'un motif simple ou complexe
    
$integre = false; // marqueur de recherche intégrale sur la requête
    
$escape = false; // marqueur d'échappement
    
$finDom = false; // marqueur de fin de définition de domaine
    
$res = ''; // tampon temporaire des caractères réservés
    
$flag = ''; // marqueur de la dernière opération d'analyse effectuée
    
$argument = ''; // contient le motif ou sous-motif en cours de traitement
    
$domaine = 'm,s,'; // domaine par défaut
    
$requete = ' WHERE '; // requête SQL
    
$dom = array(); // tableau contenant pour chaque niveau de parenthèse le domaine de recherche en cours
    
$debRequete = 'SELECT mes_id FROM message '; // début de la requête SQL

    //Vérification de la présence effective d'une requête sensée
    
$test = preg_replace("/[^a-zA-Z]+/", "", $recherche);
    if (
strlen($test) < 3) {
        
$erreur['requete'] = true;
        
$Emessage .= "<li>La requête est vide ou insuffisante.";
        return
$erreur;
        }

    
//Vérification de la présence d'opérateur, sinon recherche intégrale sur le motif entier
    
if (! preg_match('/@|"|-|( not )|( and )|( or )|\||&|\+|\)|\(|!/si', $recherche)) {
        
$argument = $recherche;
        
$integre = true;
        
$fin = true;
        }

    
//Analyse syntaxique lettre par lettre
    
for ($i = 0; $i < strlen($recherche); $i++){
        
$lettre = $recherche[$i];

        if (!
$integre){
                switch(
strtolower($lettre)){
                    case
' ': //Gestion des espaces
                        
$esp = 1;
                        
$op = '';
                        
$escape = false;
                        
$dom[$par] = '';
                        
$finDom = true;

                        if (!
$guil){ // la présence d'un espace entraine la fin du traitement
                            
$argument .= $res;
                            
$res = '';
                            if (
trim($argument)) $fin = true;
                            }
                        elseif (
$guil) $res .= $lettre;
                        break;
                    case
chr(92): //Gestion du caractère d'échappement \
                        
$escape = ! $escape;

                        if (!
$escape) $res .= chr(92);

                        
$dom[$par] = '';
                        
$finDom = true;
                        
$esp = 0;
                        break;
                    case
'@':
                        if (!
$guil && ! $finDom && $dom[$par]) { // Fin de la définition du domaine de recherche
                                
$domaine = $dom[$par];
                                
$dom[$par] = '';
                                
$finDom = true;
                                
$res = '';
                                }
                        else
$res .= '@';

                        break;
                    case
'"': //Gestion des guillemets
                        
if (! $escape){
                            if (
$guil) {
                                
$fin = true;
                                if (
$res) $argument .= $res;
                                
$res = '';
                                }
                            
$guil = ! $guil;
                            }
                        else
$res .= chr(92).$lettre; // On double l'échappement pour le conserver avec MySQL;

                        
$escape = false;

                        
$dom[$par] = '';
                        
$finDom = true;

                        break;
                    case
'a': //Gestion du début éventuel du and
                        
if (! $guil && $esp) $op = 'a';
                        elseif (!
$guil && ! $finDom) {  //Gestion du domaine auteur **
                                
if (! preg_match('/a/', $dom[$par]))  $dom[$par] .= 'a,';
                                }
                        else {
                                
$dom[$par] = '';
                                
$finDom = true;
                                
$op = '';
                                }

                        
$res .= $lettre;

                        
$escape = false;
                        break;
                    case
'n':  //Gestion du début éventuel du and
                        
if (! $guil && $op == 'a') $op = 'an';
                        elseif (!
$guil && esp) $op = 'n';
                        
$res .= $lettre;

                        
$escape = false;
                        
$dom[$par] = '';
                        
$finDom = true;

                        break;
                    case
'd':  //Gestion de la fin éventuelle d'un and
                        
$res .= $lettre;
                        if (!
$guil && $op == 'an') {
                            
$operateur = "AND";
                            
$op = '';
                            
$fin = true;
                            
$res = '';
                            }

                        
$escape = false;
                        
$dom[$par] = '';
                        
$finDom = true;
                        break;
                    case
'+': //Gestion des alias de l'opérateur AND (& et +)
                    
case '&':
                        if (!
$escape && ! $guil && $esp) {
                            
$operateur = "AND";
                            
$fin = true;
                            
$op = '';
                            
$argument .= $res;
                            
$res = '';
                            }
                        else
$res .= $lettre;

                        
$escape = false;
                        
$dom[$par] = '';
                        
$finDom = true;

                        break;
                    case
'o': //Gestion du début éventuel du or ou du not
                        
if (! $guil && $esp) $op = 'o';
                        elseif (!
$guil && $op = 'n') $op = 'no';

                        
$escape = false;
                        
$res .= $lettre;
                        
$dom[$par] = '';
                        
$finDom = true;

                        break;
                    case
'r':  //Gestion de la fin éventuelle du or
                        
if (! $guil && $op == 'o') {
                            
$operateur = "OR";
                            
$op = '';
                            
$fin = true;
                            
$res = '';
                            }
                        else
$res .= $lettre;

                        
$escape = false;
                        
$dom[$par] = '';
                        
$finDom = true;

                        break;
                    case
't':  //Gestion de la fin éventuelle du not

                        
if (! $guil && $op == 'no') {
                            if (!
$negation) $negation = "NOT"; //Gestion de la double négation
                            
else $negation = '';
                            
$op = '';
                            
$res = '';
                                }
                        elseif (!
$guil && ! $finDom) { //Gestion du domaine thème **
                                
if (! preg_match('/t/', $dom[$par])) $dom[$par] .= 't,';
                                
$res .= $lettre;
                                }
                        else {
                                
$dom[$par] = '';
                                
$finDom = true;
                                
$res .= $lettre;
                                }

                        break;
                    case
'm': //Gestion du domaine message **

                        
if (! $guil && ! $finDom) {
                                if (!
preg_match('/m/', $dom[$par])) $dom[$par] .= 'm,';
                                
$res .= $lettre;
                                }
                        else {
                                
$dom[$par] = '';
                                
$finDom = true;
                                
$res .= $lettre;
                                }

                        break;
                    case
's': //Gestion du domaine sujet **
                        
$res .= $lettre;

                        if (!
$guil && ! $finDom) {
                                if (!
preg_match('/s/', $dom[$par])) $dom[$par] .= 's,';
                                }
                        else {
                                
$dom[$par] = '';
                                
$finDom = true;
                                }

                        break;
                    case
'|': //Gestion de l'alias du or (|)
                        
if (! $escape && ! $guil && $esp) {
                            
$operateur = "OR";
                            
$fin = true;
                            
$argument .= $res;
                            
$op = '';
                            
$res = '';
                            }
                        else
$res .= $lettre;

                        
$dom[$par] = '';
                        
$finDom = true;
                        
$escape = false;
                        break;
                    case
'!': //Gestion des alias du not (- !)
                    
case '-':
                        if (!
$escape && ! $guil && ($esp || !$flag)) { // Le not peut être le premier argument de la requête
                            
if (! $negation) $negation = "NOT"; //Gestion de la double négation
                            
else $negation = '';
                            
$op = '';
                            
$res = '';
                            }
                        else
$res .= $lettre;

                        
$dom[$par] = '';
                        
$finDom = true;
                        
$escape = false;
                        break;
                    case
'(': //gestion de l'ouverture d'une parenthèse
                        
if (! $guil && ! $escape) { $par ++; $ouverture = true; $fin = true;  $finDom = false;  }
                        else
$res .= $lettre;

                        
$escape = false;
                        break;
                    case
')': //gestion de la fermeture d'une parenthèse
                        
if (! $guil && ! $escape) { $argument .= $res; $res = ''; $fermeture = true; $fin = true; }
                        else
$res .= $lettre;

                                        
$dom[$par] = '';
                        
$escape = false;
                        
$finDom = true;
                        break;
                    default:
//gestion des autres caractères
                        
$op = '';
                        
$escape = false;

                        if (
$res) $argument .= $res.$lettre;
                        else
$argument .= $lettre;
                        
$res = '';

                        
$dom[$par] = '';
                        
$finDom = true;
                    }
                        }

                
//Remise à zéro des espaces
                
if ($lettre != ' ') $esp = 0;

        
//Détéction du caractère de fin
        
if ($i == strlen($recherche) - 1) $fin = true;

        
//Construction progressive de la requête
        
if ($fin){

                        
// Gestion du domaine par défaut
                
if (! $flag && $dom[$par] != '') $domaine = $dom[$par];

                        
// Récupération des données stockées
                        
if ($res) $argument .= $res;
            
$res = '';

            
//Construction de la requête avec un argument
            
if ($argument) {

                if (
$flag && ($flag != 'ope' && $flag != 'ouv' && ! $negation) && !$integre && !$err1) {
                    
$erreur['formulation'] = true;
                    
$Emessage .= "<li>Vous ne pouvez faire succéder deux arguments sans les séparer par un opérateur booléen.";
                    
$err1 = true;
                    }
                elseif(
strlen($argument) <= 3){
                    
$erreur['formulation'] = true;
                    
$Emessage .= "<li>L'argument <i>$argument</i> est trop petit pour être considéré dans la requête.";
                    }
                else {
                    
$pref = '';

                    if (
$flag != 'ope' && $flag && $negation) $pref = ' AND ';

                        
$requete .= $pref.' (';
                        
$tab = explode(',', $domaine);
                        for (
$p = 0; $p < count($tab); $p ++){ //Reconstitution du domaine de recherche **
                                
if ($tab[$p] == '') break;

                                                switch(
$tab[$p]){
                                                        case
'm':
                                                                
$tab[$p] = 'mes_texte';
                                                                break;
                                                        case
't':
                                                                
$tab[$p] = 'mes_theme';
                                                                break;
                                                        case
'a':
                                                                
$tab[$p] = 'mes_auteur';
                                                                break;
                                                        case
's':
                                                                
$tab[$p] = 'mes_titre';
                                                                break;
                                                        }

                                if (
$negation) { // gestion de la négation
                                        
if ($p) $requete .= ' AND '.$tab[$p].' NOT LIKE "%'.$argument.'%"';
                                        else
$requete .= ' '.$tab[$p].' NOT LIKE "%'.$argument.'%"';
                                        }
                                else {
// gestion classique
                                        
if ($p) $requete .= ' OR '.$tab[$p].' LIKE "%'.$argument.'%"';
                                        else
$requete .= ' '.$tab[$p].' LIKE "%'.$argument.'%"';
                                        }
                                }

                        
$requete .= ' )';
                                        }

                
$fin = false;
                
$argument = '';
                
$negation = '';
                
$not = 0;
                
$flag = 'arg';
                }

            
//Construction de la requête avec un opérateur
            
if ($operateur) {
                if (!
$flag && !$err6){
                    
$erreur['formulation'] = true;
                    
$Emessage .= "<li>Vous ne pouvez commencez votre requête par un opérateur booléen.";
                    
$err6 = true;
                    }
                elseif (
$flag && $flag == 'ope' && !$err7) {
                    
$erreur['formulation'] = true;
                    
$Emessage .= "<li>Vous ne pouvez faire succéder deux opérateurs booléens.";
                    
$err7 = true;
                    }
                else
$requete .= ' '.$operateur.' ';

                
$operateur = '';
                
$fin = false;
                
$flag = 'ope';
                }

            
//Construction de la requête avec une ouverture de parenthèse
            
if ($ouverture) {
                if (
$flag && ($flag != 'ope' && $flag != 'ouv' && !$negation) && !$err2) {
                    
$erreur['formulation'] = true;
                    
$Emessage .= "<li>Vous ne pouvez ouvrir une parenthèse sans la faire précéder d'un opérateur booléen.";
                    
$err2 = true;
                    }
                else {
                    if (
$flag != 'ope' && $flag) $pref = ' AND';
                    else
$pref = '';

                    if (
$negation) $requete .= $pref.' NOT ( ';
                    else
$requete .= ' ( ';
                    }

                
$negation = '';
                
$not = 0;
                
$ouverture = false;
                
$fin = false;
                
$flag = 'ouv';
                }

            
//Construction de la requête avec une fermeture de parenthèse
            
if ($fermeture) {
                if (
$par <= 0 && ! $err8){
                        
$erreur['formulation'] = true;
                    
$Emessage .= "<li>Vous ne pouvez fermer une parenthèse sans en avoir ouvert une avant.";
                    
$err8 = true;
                        }
                elseif (!
$flag && ! $err3){
                    
$erreur['formulation'] = true;
                    
$Emessage .= "<li>Vous ne pouvez commencez votre requête en fermant une parenthèse.";
                    
$err3 = true;
                    }
                elseif (
$flag && $flag == 'ope' && ! $err4) {
                    
$erreur['formulation'] = true;
                    
$Emessage .= "<li>Vous ne pouvez fermer une parenthèse à la suite d'un opérateur booléen.";
                    
$err4 = true;
                    }
                elseif (
$flag && $flag == 'ouv' && !$err5) {
                    
$erreur['formulation'] = true;
                    
$Emessage .= "<li>Vous ne pouvez fermer une parenthèse juste après une ouverture.";
                    
$err5 = true;
                    }
                else
$requete .= ' ) ';

                
$fermeture = false;
                
$fin = false;
                
$flag = 'fer';
                
$par --;
                }
            }
        }

    
//Calcul de la fermeture des parenthèses et des guillemets
    
if ($par != 0){
        if (
$par > 0){
            
$erreur['parentheses'] = true;
            
$Emessage .= "<li>Votre requête est mal formulée, ".$par." parenthèse".(($par > 1)? "s n'ont ":" n'a ")."pas été fermée.";
            return
$erreur;
            }
        else {
            
$erreur['parentheses'] = true;
            
$Emessage .= "<li>Votre requête est mal formulée, ".(0 - $par)." parenthèse".(((0 - $par) > 1)? 's ont ':' a ')."été fermée à tort.";
            return
$erreur;
            }
        }
    if (
$guil) {
        
$erreur['guillemets'] = true;
        
$Emessage .= "<li>Votre requête est mal formulée, des guillemets n'ont apparemment pas été fermés.";
        return
$erreur;
        }

    
//Retour des éventuelles erreurs avant la requête
    
if ($erreur) return $erreur;

    
//Test de la requête
    
$res = $db -> query($debRequete.$requete);
    if (
DB::isError($res)) { // Cas où la requête poserait encore problèmes
        
$erreur['parentheses'] = true;
        
$Emessage .= "<li> Il semblerait qu'il persiste des erreurs dans la formulation de votre requête, veuillez la ré-écrire.";
        return
$erreur;
        }

    
//Retour si aucun résultat
    
if ($res -> numRows() == 0) {
        
$erreur['vide'] = true;
        
$Emessage .= "<li> Il n'y a pas de résultats à votre requête.";
        return
$erreur;
        }

    
//Retour si requête trop imprécise
     
if ($res -> numRows() > 50) {
        
$erreur['vide'] = true;
        
$Emessage .= "<li> Votre requête a généré plus de 50 résultats, veuillez la préciser.";
        return
$erreur;
        }
    }

?>