viernes, 1 de marzo de 2013

El analizador (¡ya era hora!)



¡La última entrega! Si la cosa ha sido pesada, lo siento. Pero espero que te haya servido de algo.

Recapitulando, la última vez que nos vimos estábamos editando un fichero llamado “htmlparser.rb” y habíamos puesto en él unos cuantos “require” y la definición de la clase AttrParser. Hoy vamos a seguir con él y le añadiremos una nueva clase.

¡Y con eso habremos acabado!

Por ahora, claro.

Nuestra clase para analizar código HTML (¡al fin!) se va a llamar HTMLParser y será una subclase de Tokenizer:
class HTMLParser < Tokenizer
end

¡Hala! Añáde eso al final de “htmlparser.rb”. Y vamos a rellenar la clase...

Creando el objeto

La creación del objeto HTMLParser seguirá una estructura muy similar a la que vimos para AttrParser en el post anterior:
def initialize(html='')
            super(html)
                       
            @attrparser = AttrParser.new
                       
            comment = '<!--.*?-->'
            spaces = '[[:space:]]+'
            string1 = "'[^']*'"
            string2 = '"[^"]*"'
            nostring = "[^'\"[[:space:]]>=][^>[[:space:]]=]*"
                       
            attrs = '(?<attrs>' +
                                   '(' + spaces + '|' + string1 + '|' + string2 + '|' + nostring + '|=)*' +
                        ')'
                                  
            name = '(?<name>[a-z][^[[:space:]]>]*)' # Ignorecase está activado en Tokenizer

            # Reglas para reconocer cosas
            addRule(comment,:comment)

            addRule(/<!>/,:other)
            addRule(/<![^->].*?>/,:other)
            addRule(/<!-[^->].*?>/,:other)
            addRule('<!' + name + attrs + '>', :other)
                       
            addRule('<' + name + attrs + '>', :element)
            addRule('</' + name + attrs + '>', :close)

            addRule(/./,:text)
end

Poco nuevo que reseñar. Creamos un objeto de clase AttrParser para más adelante usarlo para analizar los atributos de las etiquetas. Después comenzamos a definir los patrones para las expresiones regulares. La primera es la de los comentarios:

            comment = '<!--.*?-->'

Aquí el interrogante tiene un significado especial. ¿Recuerdas que te dije que cuando Ruby se encuentra un carácter de repetición, como * o +, siempre se queda con la cadena más larga posible (eso del “burro grande, ande o no ande”). Pues hay veces que eso no nos interesa y ésta es una de ellas.

Queremos que, una vez iniciado el comentario con “<!--”, éste acabe en cuanto se encuentre una secuencia de final de comentario, “-->”. En medio puede haber cualquier cosa. O sea, en lenguaje de las expresiones regulares, “.*” .

Pero ¿qué pasa si hay dos comentarios? Por ejemplo, como en:
<!-- Comentario 1 -->
<a href='#abcd'>Haz clic aqu&iacute;</a>
<!-- Comentario 2 -->

Si ponemos la expresión regular
<!--.*-->

… y la comparamos con el texto anterior, tendremos dos posibles coincidencias:
         El comentario de la primera línea
         Todo el texto

Y, con la regla del burro grande, Ruby se quedará con la segunda. Que no es lo que nosotros queremos. Para que el asterisco no sea tan glotón y se conforme con lo primero que se encuentre, con la coincidencia más corta, le podemos poner detrás el carácter de cierre de interrogación (?):
<!--.*?-->

Otra construcción nueva de expresiones regulares que encontramos es la barra (|). Mediante ella podemos indicar varias opciones posibles.

Pongamos un ejemplo. Si queremos reconocer una cualquiera de las palabras “cuento”, “clase” y “casa”, podríamos usar la expresión regular:
/cuento|clase|casa/

Tendríamos otras formas de hacerlo. Dado que las tres palabras comienzan por la letra “c”, podríamos poner:
/c(uento|lase|asa)/

Observa como se usan los paréntesis para agrupar las expresiones regulares. Si quieres, puedes irte al irb y hacer pruebas:

irb(main):052:0> /c(uento|lase|asa)/.match('Esto es un cuento chino')
=> #<MatchData "cuento" 1:"uento">
irb(main):053:0> /c(uento|lase|asa)/.match('Esto es una clase de Ruby')
=> #<MatchData "clase" 1:"lase">
irb(main):054:0> /c(uento|lase|asa)/.match('Esto es una casa de ladrillo ')
=> #<MatchData "casa" 1:"asa">

Eso es lo más relevante. Fíjate, eso sí, en que, para que nuestro analizador se trague incluso el HTML mal creado, permitimos cosas raras como, por ejemplo, que las etiquetas de cierre contengan definiciones de atributos. Tu navegador posiblemente también lo haga...


Otra cosa es que la regla para los nodos de texto es muy corta:
addRule(/./,:text)

La idea es que si nos encontramos con algo que no puede ser reconocido como :element, ni como :close, ni como :comment, ni como :other, descartamos el primer carácter (que consideraremos como texto) y volvemos a probar.


Analizar el código

Eso nos obligará después a combinar todos los caracteres de texto consecutivos en un único nodo de tipo :text. Eso será en el método que extrae los distintos elementos del documento HTML y los procesa:
def parseHTML(html=nil)
            if html != nil
                        loadStr(html)
            end
                       
            # Array a retornar
            result = []
                       
            # text es usado para llevar la cuenta de
            # los nodos de texto consecutivos
            text = ''
                       
            getTokens.each do |x|
                        if x.class.name == "HTMLText"
                                   # Unir nodos de texto consecutivos en uno sólo
                                   text += x.to_s
                        else
                                   # Si hay texto pendiente, añadirlo
                                   if text != ''
                                               result.push(HTMLText.new(text))
                                               text = ''
                                   end
                                   # Añadir el nodo
                                   result.push(x)                                                            
                        end
            end
                       
            # Si hay texto pendiente, añadirlo
            if text != ''
                        result.push(HTMLText.new(text))
            end
                       
            return result
end


Las cosas del HTML

Y ya sólo nos quedarían los métodos privados que se encargan de crear los objetos que representan elementos encontrados. El más relevante es el correspondiente a las etiquetas:
def element(x)
            autoclosed = (x[:attrs][-1] == '/')
                       
            # Texto de los atributos (quitar el / del final si lo hay)
            attrs = autoclosed ? x[:attrs][0..-2] : x[:attrs]
            attrs.strip!
                       
            return HTMLTag.new(x[:name], autoclosed, @attrparser.parseAttr(attrs))
end

Lo primero que se comprueba es si el último carácter del texto extraído para los atributos  es una barra inclinada /.  Si es así, la etiqueta estará auto-cerrada y debemos eliminar el último carácter antes de analizar los atributos.

Fíjate en eso de “x[:attrs][-1]”. x[:attrs] es una cadena. Y [-1] es la forma de extraer su último carácter. Me expico: Para extraer los caracteres de una cadena se utilizan índices, como en los arrays. El cero se corresponde con el primer carácter:
irb(main):060:0> 'prueba'[0]
=> "p"
irb(main):061:0> 'prueba'[1]
=> "r"

Si lo que queremos es seleccionar un elemento contando a partir del final de la cadena hacia atrás, se usan números negativos. -1 se correspondería con el último, -2 con el penúltimo, etc.
irb(main):062:0> 'prueba'[-1]
=> "a"
irb(main):063:0> 'prueba'[-2]
=> "b"

Y, si queremos un rango de caracteres, también lo podemos indicar usando dos puntos seguidos para separar la primera y la última posición deseada. Podemos mezclar posiciones “positivas” y “negativas”:
irb(main):067:0> 'prueba'[2..4]
=> "ueb"
irb(main):068:0> 'prueba'[0..-2]
=> "prueb"
irb(main):069:0> 'prueba'[-3..-2]
=> "eb"

Al final, se crea y retorna un objeto de clase HTMLTag, al que se le pasa el nombre de la etiqueta, su condición de auto-cerrada o no y los atributos, debidamente analizados.

El resto de los tipos de “cosas HTML” tienen un tratamiento mucho menos glamouroso:
           
def close(x)
            return HTMLClose.new(x[:name])
end

def comment(x)
            return HTMLComment.new(x.to_s)
end
           
def other(x)
            return HTMLOther.new(x.to_s)
end
           
def text(x)
            return HTMLText.new(x.to_s)
end


… Y eso es todo.

La clase quedaría...

# Analizador de Documentos HTML
class HTMLParser < Tokenizer
            def initialize(html='')
                        super(html)
                       
                        @attrparser = AttrParser.new
                       
                        comment = '<!--.*?-->'
                       
                        spaces = '[[:space:]]+'
                        string1 = "'[^']*'"
                        string2 = '"[^"]*"'
                        nostring = "[^'\"[[:space:]]>=][^>[[:space:]]=]*"
                       
                        attrs = '(?<attrs>' +
                                               '(' + spaces + '|' + string1 + '|' + string2 + '|' + nostring + '|=)*' +
                                   ')'
                                  
                        name = '(?<name>[a-z][^[[:space:]]>]*)' # Ignorecase está activado

                        # Reglas para reconocer cosas
                        addRule(comment,:comment)

                        addRule(/<!>/,:other)
                        addRule(/<![^->].*?>/,:other)
                        addRule(/<!-[^->].*?>/,:other)
                        addRule('<!' + name + attrs + '>', :other)
                       
                        addRule('<' + name + attrs + '>', :element)
                        addRule('</' + name + attrs + '>', :close)

                        addRule(/./,:text)
            end

            # Analizar una cadena con código HTML
            # Parámetro: html -> cadena con el código a analizar
            # Retorna: Un array de elementos HTML
            def parseHTML(html=nil)
                        if html != nil
                                   loadStr(html)
                        end
                       
                        # Array a retornar
                        result = []
                       
                        # text es usado para llevar la cuenta de
                        # los nodos de texto consecutivos
                        text = ''
                       
                        getTokens.each do |x|
                                   if x.class.name == "HTMLText"
                                               # Unir nodos de texto consecutivos en uno sólo
                                               text += x.to_s
                                   else
                                               # Si hay texto pendiente, añadirlo
                                               if text != ''
                                                           result.push(HTMLText.new(text))
                                                           text = ''
                                               end
                                               # Añadir el nodo
                                               result.push(x)                                                            
                                   end
                        end
                       
                        # Si hay texto pendiente, añadirlo
                        if text != ''
                                   result.push(HTMLText.new(text))
                        end
                       
                        return result
            end
private
            # Methods for rules post-processing
            def element(x)
                        autoclosed = (x[:attrs][-1] == '/')
                       
                        # Texto de los atributos (quitar el / del final si lo hay)
                        attrs = autoclosed ? x[:attrs][0..-2] : x[:attrs]
                        attrs.strip!
                       
                        return HTMLTag.new(x[:name], autoclosed, @attrparser.parseAttr(attrs))
            end
           
            def close(x)
                        return HTMLClose.new(x[:name])
            end

            def comment(x)
                        return HTMLComment.new(x.to_s)
            end
           
            def other(x)
                        return HTMLOther.new(x.to_s)
            end
           
            def text(x)
                        return HTMLText.new(x.to_s)
            end
end # class HTMLParser



¡Gracias por llegar hasta aquí!

Nos vemos pronto en el próximo proyecto.

No hay comentarios:

Publicar un comentario