viernes, 1 de marzo de 2013

Analizando atributos



Ya vamos acabando con nuestro proyecto. Parecía al principio que era algo muy complicado y hoy, si no te aburres de leerme, vas a terminar la penúltima de sus fases.

Pero no te preocupes, que algo más que hacer buscaremos.

Los atributos

Lo primero que vamos a construir es un analizador al que le pasaremos una cadena con los atributos de una etiqueta y nos la convertirá en un hash perteneciente a nuestra clase Attributes.

HTML es un lenguaje muy permisivo. Y los navegadores admiten incluso documentos peor construidos de los que el lenguaje permite. Eso nos va a complicar un poco, sólo un poco, las cosas. Sólo es cuestión de mirar qué hacen los navegadores cuando se encuentran con algo raro y copiarnos de ellos.

Observemos la siguiente etiqueta

<table a=cadena b c=1 b= 'cadena' c = "cadena1" >

El nombre de la etiqueta es “table”.

Y sus atributos vienen dados por:
 a=cadena b c=1 b= 'cadena' c = "cadena1"

Donde se puede observar que las asignaciones de valores pueden tomar varias formas. En primer lugar, las cadenas pueden ir encerradas entre comillas simples o dobles. O incluso no ir encerradas entre comillas, en cuyo caso acaban en cuanto se encuentra un espacio o el final de la etiqueta (>).

Pero es que, además, puede que aparezca sólo el nombre del atributo. Como la “b” del ejemplo. En estos casos, se supondrá que el valor asignado es “1”.

Aparte de eso, y por muy mal que esté, vamos a tolerar que aparezcan cadenas encerradas entre comillas dobles o simples donde no vengan a cuento. Eso sí, las vamos a ignorar. Para nosostros, como si no existieran. Un ejemplo:
 a=cadena 'cadena fuera de lugar' b c=1 b= 'cadena' c = "cadena1"

O sea, lo que en lenguaje técnico se denomina "una porquería". Por no soltar aquí palabras malsonantes.

¿Que, a pesar de todo, queremos analizar esto con Ruby? ¡OK! Ya tenemos la clase Tokenizer, de modo que sólo es cuestión de crear las expresiones regulares.

Incluye tus ficheros

Vamos a crear un fichero llamado “htmlparser.rb” en la misma carpeta en la que tenemos los otros que hemos ido creando hasta ahora.

Lo primero será incluir los archivos que contienen las clases que vamos a necesitar:

require File.dirname(__FILE__) + '/tokenizer.rb'
require File.dirname(__FILE__) + '/attributes.rb'
require File.dirname(__FILE__) + '/htmlthings.rb'

Ya tenemos disponibles los contenidos de “tokenizer.rb”, “attributes.rb” y “htmlthings.rb”. Y tú me dirás: “¿Pero htmlthings.rb no hacía ya un require de attributes.rb? ¿Qué pasa, que vamos a procesar  attributes.rb dos veces?”

No en este caso. Porque require comprueba si el fichero ha sido ya cargado antes y, de ser así, no hace nada. O sea, que si un fichero se incluye más de una vez con “require” no pasa nada.

Habrá ocasiones en que sí nos interese cargar dos veces un mismo fichero. Para eso, en lugar de “require”, se usa “load”.

Analizador de atributos

Vamos ahora con la clase que analiza los atributos. Se llamará AttrParser y, para empezar, sólo indicaremos que es una subclase de Tokenizer:

class AttrParser < Tokenizer
end

Momento de pensar un poco. ¿Qué expresiones regulares necesitamos?

Comencemos por algo simple. Tenemos que descartar todos los espacios, tabuladores, retornos de línea y similares. Ruby tiene una forma de indicar este tipo de caracteres:    [[:space:]] .

En las expresiones regulares, cuando uno se encuentra con dos corchetes de apertura seguidos, [[, a continuación el carácter de dos puntos, :, un nombre, otra vez los dos puntos , :, y dos corchetes de cierre, ]], eso indica una categoría de caracteres. En el caso de “space”, como se indicó antes, se trata de los separadores de palabras: espacio en blanco, tabulador, retorno de carro, etc.

Para hacer más legible el código vamos a crear cadenas con los principales elementos a reconocer y después las combinaremos para crear expresiones más complejas. Recuerda que Tokenizer convertirá de motu propio estas cadenas en expresiones regulares.

Así, para una secuencia de cero o más espacios consecutivos tendriamos:

spaces = '[[:space:]]*'

Le toca ahora el turno a los nombres de atributos. Para simplificar, vamos a considerar que estos nombres empiezan por un carácter que no es ni un espacio ni un signo igual (=), ni comillas simples ni comillas dobles. Eso, como expresión regular, sería:

[^[[:space:]]='"]

Observa que tenemos una serie de cosas entre corchetes y recuerda que eso quería decir: “un carácter cualquiera de éstos”. Pero esta vez lo primero que hay es un carácter ^. Cuando ocurre esto, se invierte el significado: “un carácter que no sea niguno de éstos”. Ni [[:space:]], ni =, ni ', ni ".

Ya hemos detectado el primer elemento del nombre. ¿Qué pasa con el segundo, el tercer y todos los demás? ¿Hasta dónde y cuándo seguirán siendo los caracteres parte del nombre?

Pues... hasta que encontremos un espacio o un signo igual. O cuando se acaben los caracteres de la cadena. Cualquier otra cosa seguirá siendo parte del nombre:
[^[[:space:]]=]*

Poniéndolo todo junto, un nombre de atributo vendría dado por

[^[[:space:]]='"][^[[:space:]]=]*


Partes con nombre

Pero vamos a ir un paso más allá. Ruby nos permite hacer cosas muy curiosas con las expresiones regulares y una de ellas es ponerle nombres a sus partes. Supongamos que queremos reconocer subcadenas formadas por una letra y un dígito. Tendríamos la expresión regular:

[A-Za-z][0-9]

Ruby nos permite poner un nombre a una parte de la expresión regular para más adelante recuperar su valor. Supongamos que después querremos saber qué letra se encontró. Podríamos usar entonces

(?<letra>[A-Za-z])[0-9]

Fíjate: hemos puesto la parte de la expresión regular que queremos reconocer entre paréntesis y, junto al paréntesis de apertura, hemos puesto “?<letra>” para ponerle un nombre (“letra”). Con irb podemos ver cómo funciona:

irb(main):043:0> coincidencia = /(?<letra>[A-Za-z])[0-9]/.match('1232!Qe3 ')
=> #<MatchData "e3" letra:"e">
irb(main):044:0> coincidencia[:letra]
=> "e"
irb(main):045:0>

Observa que el objeto de clase MatchData que representa la coincidencia guarda una referencia a la “letra” y que ésta se puede extraer aplicándole el operador [] con un argumento Symbol.

Volviendo al nombre de los atributos, y representándolo ya como cadena, quedaría:

name = '(?<attr>[^[[:space:]]=\'"][^[[:space:]]=]*)'

Lo único raro, aparte de que hemos puesto el nombre “attr” al nombre del atributo, es ese \' que sirve para meter una comilla simple dentro de una cadena encerrada entre comillas simples. Otro uso de las “secuencias de escape”: quitarle el significado especial a los caracteres.

Sigamos. Entre el nombre del atributo y su correspondiente valor pueden ir unos espacios opcionales, el signo igual y otros espacios opcionales. Recuerda que para lo de los espacios habíamos creado una variable llamada “spaces”, lo que nos deja:

equal = spaces + '=' + spaces

Y después de eso el valor. Que puede ser una cadena encerrada entre comillas simples. A lo que va entre las comillas le pondremos como nombre “val”:
'(?<val>[^']*)'

La parte con nombre “val” exluye, como ves, las comillas. Será la que contenga el valor del atributo.

A la hora de crear una cadena con este contenido, y para evitar problemas, lo encerraremos entre comillas dobles:
string1 = "'(?<val>[^']*)'"


Algo similar podemos hacer con las cadenas entre comillas dobles:
"(?<val>[^"]*)"

… y tendríamos, encerrándola entre comillas simples para formar una cadena:
string2 = '"(?<val>[^"]*)"'
                       

Finalmente, definiremos las cadenas no encerradas entre comillas de forma similar a los nombres de atributos:
literal = '(?<val>[^[[:space:]]=\'"][^[[:space:]]=]*)'

                       
Con los componentes que hemos definido, es posible crear las reglas que reconocen los atributos y que responderían a las estructuras:
nombre
nombre = 'cadena entre comillas simples'
nombre = "cadena entre comillas dobles"
nombre = cadenasincomillas

… y que, utilizando los métodos heredados de la clase Tokenizer, quedaría

addRule(name, :attribute)
addRule(name+equal+string1, :attribute)
addRule(name+equal+string2, :attribute)
addRule(name+equal+literal, :attribute)

También tenemos que decirle que debe ignorar los espacios sobrantes y las cadenas que aparezcan fuera de lugar:

addRule(spaces, :ignore)
addRule(string1, :ignore)
addRule(string2, :ignore)

Observa que sólo hemos definido dos tipos de elementos:
- :attribute para las asignaciones de valores a atributos
- :ignore para cosas a ignorar

Qué hacer con cada cosa

Ha llegado el turno de decirle a nuestra clase qué tiene que hacer cuando encuentre un atributo y cuando encuentre algo a ignorar. Para eso crearemos dos métodos privados con los mismos nombres que les pusimos a los tipos de elemento: “attribute” e “ignore”:

            # Atributos
            def attribute(x)
                        val = x.names.include?("val") ? x[:val] : 1
                        return {x[:attr]=>val}
            end
                       
            # Cosas a ignorar       
            def ignore(x)
                        return :ignore
            end

Para las cosas a ignorar, simplemente retornamos :ignore. Para los atributos hacemos un poco más:

Recuerda que estaríamos hablando de cosas que han coincidido con expresiones regulares del tipo:

(?<attr>[^[[:space:]]='"][^[[:space:]]=]*)[[:space:]]=[[:space:]]'(?<val>[^']*)'

o

(?<attr>[^[[:space:]]='"][^[[:space:]]=]*)

Para empezar, comprobamos si el MatchData que recibimos tiene una parte a la que le hayamos puesto el nombre “val”. De existir, ése sería el valor asignado al atributo (eso es lo que quiere decier x[:val] ). En caso contrario tomamos “1” como valor por defecto.

Hecho esto, retornamos un hash de un único elemento del tipo { nombre_del_atributo => valor }.

Ya sólo quedaría automatizar el análisis de todos los atributos que vengan definidos en una misma cadena:

def parseAttr(str)
            ret = Attributes.new
                       
            getTokens(nil,str).delete_if{|thing| thing==:ignore}.each do |hash|
                        ret.copyHash(hash)
            end
           
            return ret
end

Para empezar, se crea un objeto de tipo Attributes (recuerda, aquella subclase de Hash que no distingue si sus índices están en mayúsculas o minúsculas).

Después se extraen todos los tokens de la cadena. El resultado es un Array con los elementos obtenidos, que podrán ser de tipo :ignore o bien hashes con atributos. Al resultado se le aplica el método “delete_if”.

delete_if elimina de un Array aquellos elementos que cumplan una determinada condición, la cual se puede especificar mediante un bloque de código que, en nuestro caso es
{|thing| thing==:ignore}

O sera, se eliminan los elementos que sean igual a :ignore. Descartados los elementos a ignorar, nos quedará una serie de hashes {nombre_de_atributo => valor} que iremos añadiendo a la variable “ret”.
Terminado el proceso, ret contendrá los atributos en forma de hash de clase Attributes.

Ya tenemos la clase AttrParser.


Y así nos quedó

# Analizador de atributos de un elemento
class AttrParser < Tokenizer
            def initialize(html = '')
                        super(html) # Crear las componentes básicas del Tokenizer
                       
                        # Expresiones regulares a reconocer
                        spaces = '[[:space:]]*'
                        name = '(?<attr>[^[[:space:]]=\'"][^[[:space:]]=]*)'
                        equal = spaces + '=' + spaces
                        string1 = "'(?<val>[^']*)'"
                        string2 = '"(?<val>[^"]*)"'
                        literal = '(?<val>[^[[:space:]]=\'"][^[[:space:]]=]*)'
                       
                        # Reglas para analizar los atributos
                        addRule(name, :attribute)
                        addRule(name+equal+string1, :attribute)
                        addRule(name+equal+string2, :attribute)
                        addRule(name+equal+literal, :attribute)
                       
                        # Reglas para cosas a ignorar            
                        addRule(spaces, :ignore)
                        addRule(string1, :ignore)
                        addRule(string2, :ignore)
            end
           
            # Analizar una cadena con definición de atributos
            def parseAttr(str)
                        ret = Attributes.new
                       
                        getTokens(nil,str).delete_if{|thing| thing==:ignore}.each do |hash|
                                   ret.copyHash(hash)
                        end
                       
                        return ret
            end
private
            # Atributos
            def attribute(x)
                        val = x.names.include?("val") ? x[:val] : 1
                        return {x[:attr]=>val}
            end
                       
            # Cosas a ignorar       
            def ignore(x)
                        return :ignore
            end

end #class AttrParser


No hay comentarios:

Publicar un comentario