def parse_rule(l, cats):
    """Parses a sound change rule or category.

    Given a line l with the format 'a > b / c_d ! e_f', produces a dict of
    the form
    {
        'from': 'a',
        'to': 'b',
        'before': 'c',
        'after': 'd',
        'unbefore': 'e',
        'unafter': 'f'
    }.
    If l is of the form 'a > b / c_d ! e_f | g > h / i_j ! k_l', the output is
    a list of the '|' delimited rules.
    If l is of the form 'a = b c d', the output is a dict of the form
    {
        'cat_name': 'a',
        'category': ['b', 'c', 'd']
    }. Category names in curly brackets are expanded.

    Args:
        l: The line of text to parse.
        cats: The dict of categories to use in the rule.

    Returns:
        A dict representing either a sound change rule or category, or a list
        of several sound changes.
    """
    word_start = r'(?<=^|\s)'
    word_end = r'(?=$|\s)'
    out = {}
    if len(l.split(' = ')) == 2:
        # If there is an equals sign, it's a category
        out['cat_name'] = l.split(' = ')[0].strip()
        category = l.split(' = ')[1]
        # expand categories
        for c in cats:
            category = category.replace('{' + c + '}', ' '.join(cats[c]))
        out['category'] = category.split()
    else:
        if len(l.split(' | ')) > 1:
            # It's a list of sound changes
            return [parse_rule(ll, cats) for ll in l.split(' | ')]
        # Otherwise, it's a sound change rule
        try:
            # Attempt to set 'from' and 'to'. If there isn't a ' > ', it will
            # raise an IndexError when trying to set 'to', so 'from' will be
            # set, but 'to' will not. This could be used when parsing a rule to
            # be used as a search pattern, and not as a sound change. Need to
            # split on ' / ' and ' ! ' in case it is being used in this way.
            out['from'] = l.split(' > ')[0].split(' / ')[0].split(' ! ')[0]
            # Treat '0' like ''
            if out['from'] == '0':
                out['from'] = ''
            out['to'] = l.split(' > ')[1].split(' / ')[0].split(' ! ')[0]
            # Treat '0' like ''
            if out['to'] == '0':
                out['to'] = ''
        except IndexError:
            pass
        try:
            # Attempt to set 'before' and 'after'. If there isn't a ' / ', it
            # will raise an IndexError, and neither will be set. If there isn't
            # a '_', it will raise an IndexError when trying to set 'after', so
            # 'before' will be set, but 'after' will not.
            out['before'] = l.split(' / ')[1].split('_')[0].split(' ! ')[0]
            out['before'] = out['before'].replace('#', word_start)
            out['after'] = l.split(' / ')[1].split('_')[1].split(' ! ')[0]
            out['after'] = out['after'].replace('#', word_end)
        except IndexError:
            pass
        try:
            # Attempt to set 'unbefore' and 'unafter'. Same comments apply as
            # for 'before' and 'after'. Note that the negative conditions must
            # come after the positive conditions, if both exist.
            out['unbefore'] = l.split(' ! ')[1].split('_')[0]
            out['unbefore'] = out['unbefore'].replace('#', word_start)
            out['unafter'] = l.split(' ! ')[1].split('_')[1]
            out['unafter'] = out['unafter'].replace('#', word_start)
        except IndexError:
            pass
    return out