Reading 18: Regular Expressions & Grammars
Software in 6.031
Objectives
After today’s class, you should:
- Understand the ideas of grammar productions and regular expression operators
- Be able to read a grammar or regular expression and determine whether it matches a sequence of characters
- Be able to write a grammar or regular expression to match a set of character sequences and parse them into a data structure
Introduction
Today’s reading introduces several ideas:
Some program modules take input or produce output in the form of a sequence of bytes or a sequence of characters, which is called a string when it’s simply stored in memory, or a stream when it flows into or out of a module. In today’s reading, we talk about how to write a specification for such a sequence. Concretely, a sequence of bytes or characters might be:
- A string
- A file on disk, in which case the specification is called the file format
- Messages sent over a network, in which case the specification is a wire protocol
- A command typed by the user on the console, in which case the specification is a command line interface
For these kinds of sequences, we introduce the notion of a grammar, which allows us not only to distinguish between legal and illegal sequences, but also to parse a sequence into a data structure that a program can work with. The data structure produced from a grammar will often be a recursive data type like we talked about in the recursive data types reading.
We also talk about a specialized form of a grammar called a regular expression. In addition to being used for specification and parsing, regular expressions are a widely-used tool for many string-processing tasks that need to disassemble a string, extract information from it, or transform it.
The next reading will talk about parser generators, a kind of tool that translates a grammar automatically into a parser for that grammar.
Grammars
To describe a string of symbols, whether they are bytes, characters, or some other kind of symbol drawn from a fixed set, we use a compact representation called a grammar.
A grammar defines a set of strings. Suppose we want to write a grammar that represents URLs. Our grammar for URLs will specify the set of strings that are legal URLs in the HTTP protocol.
The literal strings in a grammar are called terminals. They’re called terminals because they can’t be expanded any further.
We generally write terminals in quotes, like 'http'
or ':'
.
A grammar is described by a set of productions, where each production defines a nonterminal. You can think of a nonterminal like a variable that stands for a set of strings, and the production as the definition of that variable in terms of other variables (nonterminals), operators, and constants (terminals). Nonterminals are internal nodes of the tree representing a string.
A production in a grammar has the form
nonterminal ::= expression of terminals, nonterminals, and operators
One of the nonterminals of the grammar is designated as the root.
The set of strings that the grammar recognizes are the ones that match the root nonterminal.
This nonterminal is sometimes called root
or start
or even just S
, but in the grammars below we will typically choose more readable names for the root, like url
, html
, and markdown
.
So a grammar that represents a singleton set, allowing only one specific URL, might have just one production defining the nonterminal url
, with a terminal on the righthand side:
url ::= 'http://mit.edu/'
Grammar operators
Productions can use operators to combine terminals and nonterminals on the righthand side. The three most important operators in a production expression are:
x ::= y* // x matches zero or more y
Concatenation, represented not by a symbol, but just a space:
x ::= y z // x matches y followed by z
Union, also called alternation, represented by |
:
x ::= y | z // x matches either y or z
By convention, postfix operators like *
have highest precedence, which means they are applied first.
Concatenation is applied next.
Alternation |
has lowest precedence, which means it is applied last.
Parentheses can be used to override precedence:
m ::= a (b|c) d // m matches a, followed by either b or c, followed by d
x ::= (y z | a b)* // x matches zero or more yz or ab pairs
Let’s use these operators to generalize our url
grammar to match some other hostnames, such as http://stanford.edu/
and http://google.com/
.
url ::= 'http://' hostname '/'
hostname ::= 'mit.edu' | 'stanford.edu' | 'google.com'
The first rule of this grammar demonstrates concatenation.
The url
nonterminal matches strings that start with the literal string http://
, followed by a match to the hostname
nonterminal, followed by the literal string /
.
The hostname
rule demonstrates union.
A hostname
can match one of the three literal strings, mit.edu
or stanford.edu
or google.com
.
So this grammar represents the set of three strings, http://mit.edu/
, http://google.com/
, and http://stanford.edu/
.
Let’s take it one step further by allowing any lowercase word in place of mit
, stanford
, google
, com
and edu
:
url ::= 'http://' hostname '/'
hostname ::= word '.' word
word ::= letter*
letter ::= ('a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i'
| 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q'
| 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z')
The new word
rule matches a string of zero or more lowercase letters, so the overall grammar can now match http://alibaba.com/
and http://zyxw.edu/
as well.
Unfortunately word
can also match an empty string, so this url
grammar also matches http://./
, which is not a legal URL.
Here’s a verbose way to fix that, which requires word
to match at least one letter.
word ::= letter letter*
More grammar operators
You can also use additional operators which are just syntactic sugar (i.e., they’re equivalent to combinations of the main three operators).
0 or 1 occurrence is represented by ?
:
x ::= y? // an x is a y or is the empty string
// equivalent to x ::= | y
// ^--- note the empty string here
1 or more occurrences is represented by +
:
x ::= y+ // an x is one or more y
// equivalent to x ::= y y*
An exact number of occurrences is represented by {n}
, and a range of occurences by {n,m}
, {n,}
, or {,m}
:
x ::= y{3} // an x is three y
// equivalent to x ::= y y y
x ::= y{1,3} // an x is between one and three y
// equivalent to x ::= y | y y | y y y
x ::= y{,4} // an x is at most four y
// equivalent to x ::= | y | y y | y y y | y y y y
// ^--- note the empty string here
x ::= y{2,} // an x is two or more y
// equivalent to x ::= y y y*
A character class [...]
represents the set of single-character strings matching any of the characters listed in the square brackets:
x ::= [aeiou] // equivalent to x ::= 'a' | 'e' | 'i' | 'o' | 'u'
Ranges of characters can be described compactly using -
:
x ::= [a-ckx-z] // equivalent to x ::= 'a' | 'b' | 'c' | 'k' | 'x' | 'y' | 'z'
An inverted character class [^...]
represents the set of single-character strings matching a character not listed in the brackets:
x ::= [^a-c] // equivalent to x ::= 'd' | 'e' | 'f' | ... | '0' | '1' | ... | '!' | '@'
// | ... (all other possible characters)
These additional operators allow the word
production to be expressed more compactly:
url ::= 'http://' hostname '/'
hostname ::= word '.' word
word ::= [a-z]+
Recursion in grammars
How else do we need to generalize? Hostnames can have more than two components, and there can be an optional port number:
http://didit.csail.mit.edu:4949/
To handle this kind of string, the grammar is now:
url ::= 'http://' hostname (':' port)? '/'
hostname ::= word '.' hostname | word '.' word
port ::= [0-9]+
word ::= [a-z]+
Notice how hostname is now defined recursively in terms of itself. Which part of the hostname definition is the base case, and which part is the recursive step? What kinds of hostnames are allowed?
Using the repetition operator, we could also write hostname
without recursion, like this:
hostname ::= (word '.')+ word
Recursion can sometimes be eliminated from a grammar using operators like this, but not always.
Another thing to observe is that this grammar allows port numbers that are not technically legal, since port numbers can only range from 0 to 65535 (216-1). We could write a more complex definition of port
that would match only these integers, but that’s not typically done in a grammar. Instead, the constraint 0 ≤ port
≤ 65535 would be specified in the program that uses the grammar.
There are more things we should do to go farther:
- generalizing
http
to support the additional protocols that URLs can have - generalizing the
/
at the end to a slash-separated path - allowing hostnames with the full set of legal characters instead of just a-z
reading exercises
Suppose we want the url
grammar to also match strings of the form:
https://websis.mit.edu/
ftp://ftp.athena.mit.edu/
ptth://web.mit.edu/
mailto:bitdiddle@mit.edu
url ::= protocol '://' hostname (':' port)? '/'
protocol ::= TODO
hostname ::= word '.' hostname | word '.' word
port ::= [0-9]+
word ::= [a-z]+
(missing explanation)
Parse trees
Matching a grammar against a string can generate a parse tree that shows how parts of the string correspond to parts of the grammar.
The leaves of the parse tree are labeled with terminals, representing the parts of the string that have been parsed. They don’t have any children, and can’t be expanded any further. If we concatenate the leaves together in order, we get back the original string. A trivial example is the one-line URL grammar that we started with, whose (only possible) parse tree is shown at the right:
url ::= 'http://mit.edu/'
Internal nodes of the parse tree are labeled with nonterminals.
The immediate children of a nonterminal node must follow the pattern of the nonterminal’s production rule in the grammar.
For example, in our more elaborate URL grammar that allows any two-part hostname, the children of a hostname
node in the tree must follow the pattern of the hostname
rule, word '.' word
.
The figure on the right shows the parse tree produced by matching this grammar against http://mit.edu/
:
url ::= 'http://' hostname '/'
hostname ::= word '.' word
word ::= [a-z]+
For a more elaborate example, here is the parse tree for the recursive URL grammar.
The tree has more structure now.
The hostname
and word
nonterminals are labeling nodes of the tree whose subtrees match those rules in the grammar.
url ::= 'http://' hostname (':' port)? '/'
hostname ::= word '.' hostname | word '.' word
port ::= [0-9]+
word ::= [a-z]+
reading exercises
If the same string from the previous exercise was matched against this grammar with a non-recursive hostname rule:
url ::= 'http://' hostname (':' port)? '/'
hostname ::= (word '.')+ word
port ::= [0-9]+
word ::= [a-z]+
then how many internal nodes (labeled by nonterminals) would the resulting parse tree have? Hint: try to draw the parse tree on paper before counting.
(missing explanation)
Example: Markdown and HTML
Now let’s look at grammars for some file formats. We’ll be using two different markup languages that represent typographic style in text. Here they are:
Markdown:
This is _italic_.
(To learn about Markdown, see the Markdown syntax documentation or Markdown on Wikipedia.)
HTML:
Here is an <i>italic</i> word.
(To learn about HTML, see the HTML Living Standard, or HTML on Wikipedia.)
For simplicity, our example HTML and Markdown grammars will only specify italics, but other text styles are of course possible.
Also for simplicity, we will assume the plain text between the formatting delimiters isn’t allowed to use any formatting punctuation, like _
or <
.
Here’s the grammar for our simplified version of Markdown:
markdown ::= ( normal | italic ) *
italic ::= '_' normal '_'
normal ::= text
text ::= [^_]*
Here’s the grammar for our simplified version of HTML:
html ::= ( normal | italic ) *
italic ::= '<i>' html '</i>'
normal ::= text
text ::= [^<>]*
reading exercises
Look at the markdown
and html
grammars above, and compare their italic
productions. Notice that not only do they differ in delimiters (_
in one case, < >
tags in the other), but also in the nonterminal that is matched between those delimiters. One grammar is recursive; the other grammar is not.
For each string below, if you match the specified grammar against it, which letters are inside matches to the italic
nonterminal?
(missing explanation)
(missing explanation)
Regular expressions
A regular grammar has a special property: by substituting every nonterminal (except the root one) with its righthand side, you can reduce it down to a single production for the root, with only terminals and operators on the right-hand side.
Our URL grammar is regular. By replacing nonterminals with their productions, it can be reduced to a single expression:
url ::= 'http://' ([a-z]+ '.')+ [a-z]+ (':' [0-9]+)? '/'
The Markdown grammar is also regular:
markdown ::= ([^_]* | '_' [^_]* '_' )*
But our HTML grammar can’t be reduced completely. By substituting righthand sides for nonterminals, you can eventually reduce it to something like this:
html ::= ( [^<>]* | '<i>' html '</i>' )*
…but the recursive use of html
on the righthand side can’t be eliminated, and can’t be simply replaced by a repetition operator either. So the HTML grammar is not regular.
The reduced expression of terminals and operators can be written in an even more compact form, called a regular expression. A regular expression does away with the quotes around the terminals, and the spaces between terminals and operators, so that it consists just of terminal characters, parentheses for grouping, and operator characters.
For example, the regular expression for our markdown
format is just
([^_]*|_[^_]*_)*
Regular expressions are also called regexes for short. A regex is far less readable than the original grammar, because it lacks the nonterminal names that documented the meaning of each subexpression. But many programming languages have library support for regexes (and not for grammars), and regexes are much faster to match than a grammar.
The regex syntax commonly implemented in programming language libraries has a few more special characters, in addition to the ones we used above in grammars. Here are some common useful ones:
. // matches any single character (but sometimes excluding newline, depending on the regex library)
\d // matches any digit, same as [0-9]
\s // matches any whitespace character, including space, tab, newline
\w // matches any word character including underscore, same as [a-zA-Z_0-9]
Backslash is also used to “escape” an operator or special character so that it matches literally. Here are some of the common special characters that you need to escape:
\. \( \) \* \+ \| \[ \] \\
Using backslashes is important whenever there are terminal characters that would be confused with special characters.
Because our url
regular expression has .
in it as a terminal, we need to use a backslash to escape it:
http://([a-z]+\.)+[a-z]+(:[0-9]+)?/
Another way to escape a special character is to wrap it in character-class brackets.
Instead of \.
, we could also write [.]
to match just the literal .
character.
Inside character-class brackets, most special characters lose their special meaning, and are simply treated literally.
But characters that are special to the character-class syntax, like [
, ]
, ^
, -
, and \
, still need to be escaped by a backslash to be used literally.
reading exercises
Using regular expressions in practice
Regexes are widely used in programming, and you should have them in your toolbox.
In TypeScript/JavaScript, you can use regexes for manipulating strings (see string.split
, string.match
, RegExp
).
They’re built-in as a first-class feature of other modern scripting languages like Python and Ruby, and you can use them in many text editors for find and replace.
In some languages (including TypeScript/JavaScript), regexes have their own literal syntax delimited by forward slashes, /..../
.
In others (including Python and Java), there is no special syntactic support for regexes, so you just use a quoted string.
Here are some examples of using regexes in TypeScript.
Replace all runs of spaces in a string s
with a single space:
const singleSpacedString = s.replace(/ +/g, " ");
Notice the g
character in the example above.
By default, a regex will only match the first instance in a string.
Adding a g
character after the regex makes this a “global” pattern which forces it to match all instances in the string.
if (s.match(/http:\/\/([a-z]+\.)+[a-z]+(:[0-9]+)?\//)) {
// then s is a url
}
Notice the backslashes in the example above.
We want to match a literal period .
, so we have to first escape it as \.
to protect it from being interpreted as the regex match-any-character operator.
Furthermore, we want to match literal forward slashes /
, so we also have to escape them as \/
to prevent them from prematurely signalling the end of the regex literal.
The frequent necessity for backslash escapes makes regexes less readable.
Extract parts of a date like "2020-03-18"
:
const s = "2020-03-18";
const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const m = s.match(regex);
if (m) {
assert(m.groups);
const year = m.groups.year;
const month = m.groups.month;
const day = m.groups.day;
// m.groups.name is the part of s that matched (?<name>...)
}
This example uses named capturing groups like (?<year>...)
to extract parts of the matched string and assign them names.
The (?<name>...)
syntax matches the regex ...
inside the parentheses, and then assigns name to the string that match.
Note that ?
here does not mean 0 or 1 repetition.
In this context, right after an open parenthesis, the ?
signals that these parentheses have special meaning, not just grouping.
Named capturing groups can be retrieved by the groups property after a successful match.
If this regex were matched against "2025-03-18"
, for example, then m.groups.year
would return "2025"
, m.groups.month
would return "03"
, and m.groups.day
would return "18"
.
reading exercises
Write the shortest regex you can to remove single-word, lowercase-letter-only HTML tags from a string:
const input = "The <b>Good</b>, the <i>Bad</i>, and the <strong>Ugly</strong>";
const regex = /TODO/g;
const output = input.replace(regex, "");
The desired output for that example is "The Good, the Bad, and the Ugly"
.
What is the shortest regex that you can put in place of TODO
, to match any single-word, lowercase-letter-only HTML tag, not just the ones in this particular input string?
You may find it useful to try your answer here.
(missing explanation)
Consider this snippet of code that matches a street address:
const input = "77 Rose Court Ln";
const regex = /[0-9]+ .* (Rd|St|Ave|Ln)/;
const m = s.match(regex);
if (m) {
...
}
We want to not only match the street address, but also parse it into pieces.
Insert named capturing groups in the regular expression regex
so that we could extract these pieces after successfully matching m
:
const houseNumber = m.groups.houseNumber; // should be "77"
const streetName = m.groups.streetName; // should be "Rose Court"
const streetType = m.groups.streetType; // should be "Ln"
Start with the original regular expression [0-9]+ .* (Rd|St|Ave|Ln)
, already filled in below, and just put correctly-named parentheses around the parts of the expression that correspond to the pieces that need to be extracted.
Take care with the spaces, because spaces have meaning in a regex.
You may find it useful to try your answer here.
(missing explanation)
Context-free grammars
In general, a language that can be expressed with our system of grammars is called context-free. Not all context-free languages are also regular; that is, some grammars can’t be reduced to single nonrecursive productions. Our HTML grammar is context-free but not regular.
The grammars for most programming languages are also context-free. In general, any language with nested structure (like nesting parentheses or braces) is context-free but not regular. That description applies to the TypeScript grammar, shown here in part:
statement ::=
'{' statement* '}'
| 'if' '(' expression ')' statement ('else' statement)?
| 'for' '(' forinit? ';' expression? ';' forupdate? ')' statement
| 'while' '(' expression ')' statement
| 'do' statement 'while' '(' expression ')' ';'
| 'try' '{' statement* '}' ( catches | catches? 'finally' '{' statement* '}' )
| 'switch' '(' expression ')' '{' switchgroups '}'
| 'return' expression? ';'
| 'throw' expression ';'
| 'break' label? ';'
| 'continue' label? ';'
| expression ';'
| label ':' statement
| ';'
Summary
Machine-processed textual languages are ubiquitous in computer science. Grammars are the most popular formalism for describing such languages, and regular expressions are an important subclass of grammars that can be expressed without recursion.
The topics of today’s reading connect to our three properties of good software as follows:
Safe from bugs. Grammars and regular expressions are declarative specifications for strings and streams, which can be used directly by libraries and tools. These specifications are often simpler, more direct, and less likely to be buggy than parsing code written by hand.
Easy to understand. A grammar captures the shape of a sequence in a form that is easier to understand than hand-written parsing code. Regular expressions, alas, are often not easy to understand, because they are a one-line reduced form of what might have been a more understandable regular grammar.
Ready for change. A grammar can be easily edited, but regular expressions, unfortunately, are much harder to change, because a complex regular expression is cryptic and hard to understand.