Un analizzatore lessicale è un fase fondamentale della compilazione/interpretazione. Il suo scopo è quello di accettare in ingresso uno stream di caratteri e produrre uno stream di token, dove ogni token è rappresentato dal testo e dal tipo.

Nel nostro esempio mostreremo un semplice Lexer per espressioni regolari.
Gli operatori sono il simbolo * (chiusura di Kleene), il simbolo + (chiusura positiva) e il simbolo | (l’or tra due stringhe).
Una stringa verrà rappresentata da una sequenza di lettere o numeri e gli spazi vuoti verranno scartati.
La stringa vuota (comunemente rappresentata dalla lettera epsilon in letteratura) verrà rappresentata dal carattere $
Il lexer non applicherà nessuna strategia di recovery a seguito di un errore, ma lancerà semplicemente un eccezione UnknownTokenException.

Mostriamo dunque le varie classi che compongono l’esempio. Iniziamo dallo stream di caratteri:

com.at.analysis.CharStream

package com.at.analysis;

public class CharStream {
  private String _stream;
  private int    _currIdx = 0;

  public CharStream(String stream) {
    _stream= stream;
  }

  public char LA(int offset) {
    if (_currIdx + offset >= _stream.length() || offset < 0) {
      return 0;
    }
    return _stream.charAt(offset + _currIdx);
  }

  public char consume() {
    if (_currIdx >= _stream.length()) {
      return 0;
    }
    return _stream.charAt(_currIdx++);
  }
}

Notiamo che, banalmente, lo stream itera su una stringa, può consumare un carattere (legge e avanza il cursore identificato da _currIdx) o guardare avanti nella stringa (look ahead) con il metodo public char LA(int offset). Tale metodo consente di leggere il carattere corrente senza consumarlo se invocato con parametro offset = 0.
Nello specifico, useremo questo metodo per identificare i token di tipo stringa.

Dallo stream di caratteri creeremo uno stream di token. La classe che rappresenta quest’ultimo è questa:

com.at.analysis.TokenStream

package com.at.analysis;

import java.util.List;

public class TokenStream {
  private List<Token> _stream;
  private int         _currIdx;

  public TokenStream(List<Token> stream) {
    _stream= stream;
    _currIdx= 0;
  }

  public Token LA(int offset) {
    if (_currIdx + offset >= _stream.size()) {
      return null;
    }
    return _stream.get(_currIdx + offset);
  }

  public Token consume() {
    if (_currIdx >= _stream.size()) {
      return null;
    }
    return _stream.get(_currIdx++);
  }

  @Override
  public String toString() {
    StringBuilder sb= new StringBuilder().append("[");
    for (int i= 0; i < _stream.size(); i++) {
      sb.append(_stream.get(i));
      if (i < _stream.size() - 1) {
        sb.append(", ");
      }
    }
    sb.append("]");
    return sb.toString();
  }
}

Un istanza di questa classe gestisce, come detto, dei token rappresentati dal testo e dal tipo:

com.at.analysis.TokenType

package com.at.analysis;

public enum TokenType {
  OPERAND, OPERATOR, LParen, RParen
}

com.at.analysis.Token

package com.at.analysis;

public class Token {
  protected String    text;
  protected TokenType type;

  public Token(char c, TokenType type) {
    this.text= String.valueOf(c);
    this.type= type;
  }

  public Token(String text, TokenType type) {
    this.text= text;
    this.type= type;
  }

  public String getText() {
    return text;
  }

  public TokenType getType() {
    return type;
  }

  @Override
  public String toString() {
    return this.getClass().getSimpleName() + " <" + text + ", " + type.toString() + ">";
  }
}

Non ci rimane dunque che da mostrare il lexer vero e proprio:

package com.at.lexer;

import java.util.ArrayList;
import java.util.List;

import com.at.analysis.CharStream;
import com.at.analysis.Token;
import com.at.analysis.TokenStream;
import com.at.analysis.TokenType;
import com.at.analysis.exc.UnknownTokenException;

public class RELexer {
  private CharStream _stream;

  public RELexer(CharStream stream) {
    _stream= stream;
  }

  public TokenStream scan() throws UnknownTokenException {
    List<Token> tokens= new ArrayList<Token>();
    //
    char c= 0;
    while ((c= _stream.consume()) != 0) {
      if (c == ' ') {
        continue;
      } else if (c == ')') {
        tokens.add(new Token(")", TokenType.RParen));
      } else if (c == '(') {
        tokens.add(new Token("(", TokenType.LParen));
      } else if (Character.isDigit(c) || Character.isLetter(c)) {
        String str= String.valueOf(c);
        while (Character.isDigit(_stream.LA(0)) || Character.isLetter(_stream.LA(0))) { // look-ahead
          str+= String.valueOf(_stream.consume());
        }
        tokens.add(new Token(str, TokenType.OPERAND));
      } else if(c == '$') {
        tokens.add(new Token("", TokenType.OPERAND));
      } else if (c == '|' || c == '*' || c == '+') {
        tokens.add(new Token(c, TokenType.OPERATOR));
      } else {
        throw new UnknownTokenException(String.valueOf(c));
      }
    }
    //
    return new TokenStream(tokens);
  }

  public static void main(String[] args) throws Throwable {
    RELexer lex= new RELexer(new CharStream("(010)*11(0|010|$)+(ab | ba)bb"));
    TokenStream tokenStream= lex.scan();
    System.out.println(tokenStream);
  }
}

I controlli dedicati alle parentesi e agli operatori sono banali. Quello dedicato al riconoscimento degli operandi ha una logica semplice: se ho appena consumato una lettera/numero, continuo a consumare dallo stream fin tanto che il look ahead mi dice che ho una lettera/numero.

Avviando il metodo main della classe, vedremo sullo standard output la lista dei token trovati dal lexer.