diff --git a/atomo.cabal b/atomo.cabal
--- a/atomo.cabal
+++ b/atomo.cabal
@@ -1,5 +1,5 @@
 name:                atomo
-version:             0.2.2
+version:             0.2.2.1
 synopsis:            A highly dynamic, extremely simple, very fun programming
                      language.
 description:
@@ -67,6 +67,8 @@
     Atomo.Method,
     Atomo.Parser,
     Atomo.Parser.Base,
+    Atomo.Parser.Expr,
+    Atomo.Parser.Expand,
     Atomo.Parser.Primitive,
     Atomo.Pattern,
     Atomo.Pretty,
diff --git a/src/Atomo/Parser/Expand.hs b/src/Atomo/Parser/Expand.hs
new file mode 100644
--- /dev/null
+++ b/src/Atomo/Parser/Expand.hs
@@ -0,0 +1,91 @@
+module Atomo.Parser.Expand (macroExpand) where
+
+import "monads-fd" Control.Monad.State
+import Text.Parsec
+import qualified "mtl" Control.Monad.Trans as MTL
+
+import Atomo.Environment
+import Atomo.Helpers
+import Atomo.Method (lookupMap)
+import Atomo.Parser.Base
+import Atomo.Pattern (match)
+import Atomo.Types
+
+
+-- | Go through an expression recursively expanding macros. A dispatch
+-- expression is checked to see if a macro was defined for it; if a macro is
+-- found, its targets are sent to the macro method (unexpanded), and the
+-- macro's result is expanded.
+--
+-- Every other expression just recursively calls macroExpand on any
+-- sub-expressions.
+macroExpand :: Expr -> Parser Expr
+macroExpand d@(Dispatch { eMessage = em }) = do
+    mm <- findMacro msg
+    case mm of
+        Just m -> do
+            modifyState $ \ps -> ps { psClock = psClock ps + 1 }
+
+            Expression e <-
+                MTL.lift (runMethod m msg >>= findExpression)
+
+            macroExpand e
+
+        Nothing -> do
+            nem <- expanded em
+            return d { eMessage = nem }
+  where
+    expanded (ESingle i n t) = do
+        nt <- macroExpand t
+        return (ESingle i n nt)
+    expanded (EKeyword i ns ts) = do
+        nts <- mapM macroExpand ts
+        return (EKeyword i ns nts)
+
+    msg =
+        case em of
+            ESingle i n t -> Single i n (Expression t)
+            EKeyword i ns ts -> Keyword i ns (map Expression ts)
+macroExpand d@(Define { eExpr = e }) = do
+    e' <- macroExpand e
+    return d { eExpr = e' }
+macroExpand s@(Set { eExpr = e }) = do
+    e' <- macroExpand e
+    return s { eExpr = e' }
+macroExpand b@(EBlock { eContents = es }) = do
+    nes <- mapM macroExpand es
+    return b { eContents = nes }
+macroExpand l@(EList { eContents = es }) = do
+    nes <- mapM macroExpand es
+    return l { eContents = nes }
+macroExpand m@(EMacro { eExpr = e }) = do -- TODO: is this sane?
+    e' <- macroExpand e
+    return m { eExpr = e' }
+macroExpand p@(EParticle { eParticle = ep }) =
+    case ep of
+        EPMKeyword ns mes -> do
+            nmes <- forM mes $ \me ->
+                case me of
+                    Nothing -> return Nothing
+                    Just e -> liftM Just (macroExpand e)
+
+            return p { eParticle = EPMKeyword ns nmes }
+
+        _ -> return p
+-- TODO: EUnquote?
+macroExpand e = return e
+
+-- | find a findMacro method for message `m' on object `o'
+findMacro :: Message -> Parser (Maybe Method)
+findMacro m = do
+    ids <- MTL.lift (gets primitives)
+    ms <- methods m
+    maybe (return Nothing) (firstMatch ids m) (lookupMap (mID m) ms)
+  where
+    methods (Single {}) = liftM (fst . psMacros) getState
+    methods (Keyword {}) = liftM (snd . psMacros) getState
+
+    firstMatch _ _ [] = return Nothing
+    firstMatch ids' m' (mt:mts)
+        | match ids' Nothing (mPattern mt) (Message m') = return (Just mt)
+        | otherwise = firstMatch ids' m' mts
diff --git a/src/Atomo/Parser/Expr.hs b/src/Atomo/Parser/Expr.hs
new file mode 100644
--- /dev/null
+++ b/src/Atomo/Parser/Expr.hs
@@ -0,0 +1,428 @@
+module Atomo.Parser.Expr where
+
+import Control.Arrow (first, second)
+import "monads-fd" Control.Monad.State
+import Data.Maybe (fromJust, isJust)
+import Text.Parsec
+import qualified "mtl" Control.Monad.Trans as MTL
+
+import Atomo.Environment
+import Atomo.Helpers (toPattern', toMacroPattern')
+import Atomo.Method (addMethod)
+import Atomo.Parser.Base
+import Atomo.Parser.Expand
+import Atomo.Parser.Primitive
+import Atomo.Types hiding (keyword, string)
+
+
+-- | The types of values in Dispatch syntax.
+data Dispatch
+    = DParticle EParticle
+    | DNormal Expr
+    deriving Show
+
+-- | The default precedence for an operator (5).
+defaultPrec :: Integer
+defaultPrec = 5
+
+-- | Parses any Atomo expression.
+pExpr :: Parser Expr
+pExpr = choice
+    [ pOperator
+    , pMacro
+    , pForMacro
+    , try pDispatch
+    , pLiteral
+    , parens pExpr
+    ]
+    <?> "expression"
+
+-- | Parses any Atomo literal value.
+pLiteral :: Parser Expr
+pLiteral = choice
+    [ pThis
+    , pBlock
+    , pList
+    , pParticle
+    , pQuoted
+    , pQuasiQuoted
+    , pUnquoted
+    , pPrimitive
+    ]
+    <?> "literal"
+
+-- | Parses a primitive value.
+--
+-- Examples: @1@, @2.0@, @3\/4@, @$d@, @\"foo\"@, @True@, @False@
+pPrimitive :: Parser Expr
+pPrimitive = tagged $ liftM (Primitive Nothing) pPrim
+
+-- | The @this@ keyword, i.e. the toplevel object literal.
+pThis :: Parser Expr
+pThis = tagged $ reserved "this" >> return (ETop Nothing)
+
+-- | An expression literal.
+--
+-- Example: @'1@, @'(2 + 2)@
+pQuoted :: Parser Expr
+pQuoted = tagged $ do
+    char '\''
+    e <- pSpacedExpr
+    return (Primitive Nothing (Expression e))
+
+-- | An expression literal that may contain "unquotes" - expressions to splice
+-- in to yield a different expression.
+--
+-- Examples: @`a@, @`(1 + ~(2 + 2))@
+pQuasiQuoted :: Parser Expr
+pQuasiQuoted = tagged $ do
+    char '`'
+    modifyState $ \ps -> ps { psInQuote = True }
+    e <- pSpacedExpr
+    modifyState $ \ps -> ps { psInQuote = False }
+    return (EQuote Nothing e)
+
+-- | An unquote expression, used inside a quasiquote.
+-- 
+-- Examples: @~1@, @~(2 + 2)@
+pUnquoted :: Parser Expr
+pUnquoted = tagged $ do
+    char '~'
+    iq <- fmap psInQuote getState
+    modifyState $ \ps -> ps { psInQuote = False }
+    e <- pSpacedExpr
+    modifyState $ \ps -> ps { psInQuote = iq }
+    return (EUnquote Nothing e)
+
+-- | Any expression that fits into one lexical "space" - either a simple
+-- literal value, a single dispatch to the toplevel object, or an expression in
+-- parentheses.
+--
+-- Examples: @1@, @[1, 2]@, @a@, @(2 + 2)@
+pSpacedExpr :: Parser Expr
+pSpacedExpr = pLiteral <|> simpleDispatch <|> parens pExpr
+  where
+    simpleDispatch = tagged $ do
+        name <- ident
+        notFollowedBy (char ':')
+        spacing
+        return (Dispatch Nothing (esingle name (ETop Nothing)))
+
+-- | The for-macro "pragma."
+--
+-- Example: @for-macro 1 print@
+pForMacro :: Parser Expr
+pForMacro = tagged (do
+    reserved "for-macro"
+    e <- pExpr
+    -- TODO: evaluate this with a specific toplevel, probably Lobby
+    macroExpand e >>= MTL.lift . eval
+    return (EForMacro Nothing e))
+    <?> "for-macro expression"
+
+-- | A macro definition.
+--
+-- Example: @macro (n squared) `(~n * ~n)@
+pMacro :: Parser Expr
+pMacro = tagged (do
+    reserved "macro"
+    p <- parens (pExpr >>= MTL.lift . toMacroPattern')
+    whiteSpace
+    e <- pExpr
+    macroExpand e >>= addMacro p
+    return (EMacro Nothing p e))
+    <?> "macro definition"
+
+-- | An operator "pragma" - tells the parser about precedence and associativity
+-- for the given operator(s).
+--
+-- Examples: @operator right 0 ->@, @operator 7 * /@
+pOperator :: Parser Expr
+pOperator = tagged (do
+    reserved "operator"
+
+    info <- choice
+        [ do
+            a <- choice
+                [ symbol "right" >> return ARight
+                , symbol "left" >> return ALeft
+                ]
+            prec <- option defaultPrec (try integer)
+            return (a, prec)
+        , liftM ((,) ALeft) integer
+        ]
+
+    ops <- operator `sepBy1` spacing
+
+    forM_ ops $ \name ->
+        modifyState (\ps -> ps { psOperators = (name, info) : psOperators ps })
+
+    return (uncurry (Operator Nothing ops) info))
+    <?> "operator pragma"
+
+-- | A particle literal.
+--
+-- Examples: @\@foo@, @\@(bar: 2)@, @\@bar:@, @\@(foo: 2 bar: _)@
+pParticle :: Parser Expr
+pParticle = tagged (do
+    char '@'
+    c <- choice
+        [ cKeyword True
+        , binary
+        , try (cSingle True)
+        , symbols
+        ]
+    return (EParticle Nothing c))
+    <?> "particle"
+  where
+    binary = do
+        op <- operator
+        return $ EPMKeyword [op] [Nothing, Nothing]
+
+    symbols = do
+        names <- many1 (anyIdent >>= \n -> char ':' >> return n)
+        spacing
+        return $ EPMKeyword names (replicate (length names + 1) Nothing)
+
+-- | Any dispatch, both single and keyword.
+pDispatch :: Parser Expr
+pDispatch = try pdKeys <|> pdChain
+    <?> "dispatch"
+
+-- | A keyword dispatch.
+--
+-- Examples: @1 foo: 2@, @1 + 2@
+pdKeys :: Parser Expr
+pdKeys = do
+    pos <- getPosition
+    msg <- keywords ekeyword (ETop (Just pos)) (try pdChain <|> headless)
+    ops <- liftM psOperators getState
+    return $ Dispatch (Just pos) (toBinaryOps ops msg)
+    <?> "keyword dispatch"
+  where
+    headless = do
+        p <- getPosition
+        msg <- ckeywd p
+        ops <- liftM psOperators getState
+        return (Dispatch (Just p) (toBinaryOps ops msg))
+
+    ckeywd pos = do
+        ks <- wsMany1 $ keyword pdChain
+        let (ns, es) = unzip ks
+        return $ ekeyword ns (ETop (Just pos):es)
+        <?> "keyword segment"
+
+-- | A chain of message sends, both single and chained keywords.
+--
+-- Example: @1 sqrt (* 2) floor@
+pdChain :: Parser Expr
+pdChain = do
+    pos <- getPosition
+
+    chain <- wsManyStart
+        (liftM DNormal (try pLiteral <|> pThis <|> parens pExpr) <|> chained)
+        chained
+
+    return $ dispatches pos chain
+    <?> "single dispatch"
+  where
+    chained = liftM DParticle $ choice
+        [ cKeyword False
+        , cSingle False
+        ]
+
+    -- start off by dispatching on either a primitive or Top
+    dispatches :: SourcePos -> [Dispatch] -> Expr
+    dispatches p (DNormal e:ps) =
+        dispatches' p ps e
+    dispatches p (DParticle (EPMSingle n):ps) =
+        dispatches' p ps (Dispatch (Just p) $ esingle n (ETop (Just p)))
+    dispatches p (DParticle (EPMKeyword ns (Nothing:es)):ps) =
+        dispatches' p ps (Dispatch (Just p) $ ekeyword ns (ETop (Just p):map fromJust es))
+    dispatches _ ds = error $ "impossible: dispatches on " ++ show ds
+
+    -- roll a list of partial messages into a bunch of dispatches
+    dispatches' :: SourcePos -> [Dispatch] -> Expr -> Expr
+    dispatches' _ [] acc = acc
+    dispatches' p (DParticle (EPMKeyword ns (Nothing:es)):ps) acc =
+        dispatches' p ps (Dispatch (Just p) $ ekeyword ns (acc : map fromJust es))
+    dispatches' p (DParticle (EPMSingle n):ps) acc =
+        dispatches' p ps (Dispatch (Just p) $ esingle n acc)
+    dispatches' _ x y = error $ "impossible: dispatches' on " ++ show (x, y)
+
+-- | A comma-separated list of zero or more expressions, surrounded by square
+-- brackets.
+--
+-- Examples: @[]@, @[1, $a]@
+pList :: Parser Expr
+pList = (tagged . liftM (EList Nothing) $ brackets (wsDelim "," pExpr))
+    <?> "list"
+
+-- | A block of expressions, surrounded by braces and optionally having
+-- arguments.
+--
+-- Examples: @{ }@, @{ a b | a + b }@, @{ a = 1; a + 1 }@
+pBlock :: Parser Expr
+pBlock = tagged (braces $ do
+    arguments <- option [] . try $ do
+        ps <- many1 pSpacedExpr
+        whiteSpace
+        string "|"
+        whiteSpace1
+        mapM (MTL.lift . toPattern') ps
+
+    code <- wsBlock pExpr
+
+    return $ EBlock Nothing arguments code)
+    <?> "block"
+
+-- | A general "single dispatch" form, without a target.
+--
+-- Used for both chaines and particles.
+cSingle :: Bool -> Parser EParticle
+cSingle p = do
+    n <- if p then anyIdent else ident
+    notFollowedBy colon
+    spacing
+    return (EPMSingle n)
+    <?> "single segment"
+
+-- | A general "keyword dispatch" form, without a head.
+--
+-- Used for both chaines and particles.
+cKeyword :: Bool -> Parser EParticle
+cKeyword wc = do
+    ks <- parens $ many1 keyword'
+    let (ns, mvs) = second (Nothing:) $ unzip ks
+    if any isOperator (tail ns)
+        then toDispatch ns mvs
+        else return $ EPMKeyword ns mvs
+    <?> "keyword segment"
+  where
+    keywordVal
+        | wc = wildcard <|> value
+        | otherwise = value
+
+    keywordDispatch
+        | wc = wildcard <|> disp
+        | otherwise = disp
+
+    value = liftM Just pdChain
+    disp = liftM Just pDispatch
+
+    keyword' = do
+        name <- try (do
+            name <- ident
+            char ':'
+            return name) <|> operator
+        whiteSpace1
+        target <-
+            if isOperator name
+                then keywordDispatch
+                else keywordVal
+        return (name, target)
+
+    wildcard = symbol "_" >> return Nothing
+
+    toDispatch [] mvs = error $ "impossible: toDispatch on [] and " ++ show mvs
+    toDispatch (n:ns) mvs
+        | all isJust opVals = do
+            os <- getState
+            pos <- getPosition
+            let msg = toBinaryOps (psOperators os) $ ekeyword opers (map fromJust opVals)
+            return . EPMKeyword nonOpers $
+                partVals ++ [Just $ Dispatch (Just pos) msg]
+        | otherwise = fail "invalid particle; toplevel operator with wildcards as values"
+      where
+        (nonOpers, opers) = first (n:) $ break isOperator ns
+        (partVals, opVals) = splitAt (length nonOpers) mvs
+
+-- | Work out precadence, associativity, etc. for a keyword dispatch.
+--
+-- The input is a keyword EMessage with a mix of operators and identifiers as
+-- its name, e.g. @EKeyword { emNames = ["+", "*", "remainder"] }@.
+toBinaryOps :: Operators -> EMessage -> EMessage
+toBinaryOps _ done@(EKeyword _ [_] [_, _]) = done
+toBinaryOps ops (EKeyword h (n:ns) (v:vs))
+    | nextFirst =
+         ekeyword [n]
+            [ v
+            , Dispatch (eLocation v)
+                (toBinaryOps ops (ekeyword ns vs))
+            ]
+    | isOperator n =
+        toBinaryOps ops . ekeyword ns $
+            (Dispatch (eLocation v) (ekeyword [n] [v, head vs]):tail vs)
+    | nonOperators == ns = EKeyword h (n:ns) (v:vs)
+    | null nonOperators && length vs > 2 =
+        ekeyword [head ns]
+            [ Dispatch (eLocation v) $
+                ekeyword [n] [v, head vs]
+            , Dispatch (eLocation v) $
+                toBinaryOps ops (ekeyword (tail ns) (tail vs))
+            ]
+    | otherwise =
+        toBinaryOps ops . ekeyword (drop numNonOps ns) $
+            (Dispatch (eLocation v) $
+                ekeyword (n : nonOperators)
+                (v : take (numNonOps + 1) vs)) :
+                drop (numNonOps + 1) vs
+  where
+    numNonOps = length nonOperators
+    nonOperators = takeWhile (not . isOperator) ns
+    nextFirst =
+        isOperator n && or
+            [ null ns
+            , prec next > prec n
+            , assoc n == ARight && prec next == prec n
+            ]
+      where next = head ns
+
+    assoc n' =
+        case lookup n' ops of
+            Nothing -> ALeft
+            Just (a, _) -> a
+
+    prec n' =
+        case lookup n' ops of
+            Nothing -> defaultPrec
+            Just (_, p) -> p
+toBinaryOps _ u = error $ "cannot toBinaryOps: " ++ show u
+
+-- | Defines a macro, given its pattern and expression.
+addMacro :: Pattern -> Expr -> Parser ()
+addMacro p e =
+    case p of
+        PSingle {} ->
+            modifyState $ \ps -> ps
+                { psMacros =
+                    ( addMethod (Macro p e) (fst (psMacros ps))
+                    , snd (psMacros ps)
+                    )
+                }
+
+        PKeyword {} ->
+            modifyState $ \ps -> ps
+                { psMacros =
+                    ( fst (psMacros ps)
+                    , addMethod (Macro p e) (snd (psMacros ps))
+                    )
+                }
+
+        _ -> error $ "impossible: addMacro: p is " ++ show p
+
+-- | Parse a block of expressions from a given input string.
+parser :: Parser [Expr]
+parser = do
+    whiteSpace
+    es <- wsBlock pExpr
+    whiteSpace
+    eof
+    return es
+
+-- | Same as `parser', but ignores a shebang at the start of the source.
+fileParser :: Parser [Expr]
+fileParser = do
+    optional (string "#!" >> manyTill anyToken (eol <|> eof))
+    parser
+
