Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ If you made changes to the runtime (any files in `src/`), you will need to rebui
julia. Run `make -j` to rebuild julia. This process may take up to 10 minutes
depending on your changes.

If you made changes to Scheme files (e.g. `src/julia-syntax.scm`, `src/julia-parser.scm`),
you can test them quickly with `make -C src` which rebuilds just the flisp bootstrap.
After that, run `make -j` to rebuild the full system with the changes.

After making changes, run static analysis checks:
- First run `make -C src install-analysis-deps` to initialize dependencies (only needed once the first time).
- Run `make -C src analyze-<filename> --output-sync -j8` (replace `<filename>` with the basename of any C or C++ file you modified, excluding headers).
Expand Down
5 changes: 5 additions & 0 deletions JuliaSyntax/src/integration/expr.jl
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,11 @@ end
elseif k == K"juxtapose"
retexpr.head = :call
pushfirst!(args, :*)
elseif k == K"match"
# Change head to match? when EXCEPT_FLAG is set
if has_flags(nodehead, EXCEPT_FLAG)
retexpr.head = Symbol("match?")
end
elseif k == K"struct"
@assert args[2].head == :block
orig_fields = args[2].args
Expand Down
5 changes: 5 additions & 0 deletions JuliaSyntax/src/julia/julia_parse_stream.jl
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ Set for K"module" when it's not bare (`module`, not `baremodule`)
"""
const BARE_MODULE_FLAG = RawFlags(1<<8)

"""
Set for K"match" when it's an exception-catching match (`match?`, not `match`)
"""
const EXCEPT_FLAG = RawFlags(1<<8)

# Flags holding the dimension of an nrow or other UInt8 not held in the source
# TODO: Given this is only used for nrow/ncat, we could actually use all the flags?
const NUMERIC_FLAGS = RawFlags(RawFlags(0xff)<<8)
Expand Down
7 changes: 7 additions & 0 deletions JuliaSyntax/src/julia/kinds.jl
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ register_kinds!(JuliaSyntax, 0, [
"try"
"using"
"while"
"match"
"BEGIN_BLOCK_CONTINUATION_KEYWORDS"
"catch"
"finally"
Expand Down Expand Up @@ -1055,6 +1056,12 @@ register_kinds!(JuliaSyntax, 0, [
"macro_name"
# Container for a single statement/atom plus any trivia and errors
"wrapper"
# Pattern matching
"matcharm"
"match-assign"
"guard"
# Declared exceptions
"postfix-?"
"END_SYNTAX_KINDS"

# Special tokens
Expand Down
209 changes: 204 additions & 5 deletions JuliaSyntax/src/julia/parser.jl
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,27 @@ struct ParseState
whitespace_newline::Bool
# Enable parsing `where` with high precedence
where_enabled::Bool
# Parsing a match pattern - disables `->` as anonymous function syntax
match_pattern::Bool
end

# Normal context
function ParseState(stream::ParseStream)
ParseState(stream, true, false, false, false, false, true)
ParseState(stream, true, false, false, false, false, true, false)
end

function ParseState(ps::ParseState; range_colon_enabled=nothing,
space_sensitive=nothing, for_generator=nothing,
end_symbol=nothing, whitespace_newline=nothing,
where_enabled=nothing)
where_enabled=nothing, match_pattern=nothing)
ParseState(ps.stream,
range_colon_enabled === nothing ? ps.range_colon_enabled : range_colon_enabled,
space_sensitive === nothing ? ps.space_sensitive : space_sensitive,
for_generator === nothing ? ps.for_generator : for_generator,
end_symbol === nothing ? ps.end_symbol : end_symbol,
whitespace_newline === nothing ? ps.whitespace_newline : whitespace_newline,
where_enabled === nothing ? ps.where_enabled : where_enabled)
where_enabled === nothing ? ps.where_enabled : where_enabled,
match_pattern === nothing ? ps.match_pattern : match_pattern)
end

# Functions to change parse state
Expand All @@ -50,7 +53,12 @@ function normal_context(ps::ParseState)
where_enabled=true,
for_generator=false,
end_symbol=false,
whitespace_newline=false)
whitespace_newline=false,
match_pattern=false)
end

function with_match_pattern(ps::ParseState)
ParseState(ps, match_pattern=true)
end

function with_space_sensitive(ps::ParseState)
Expand Down Expand Up @@ -253,12 +261,73 @@ function is_reserved_word(k)
is_keyword(k) && !is_contextual_keyword(k)
end

# Check if the current token is the identifier "match"
function is_match_identifier(ps::ParseState)
t = peek_full_token(ps.stream)
kind(t) == K"Identifier" || return false
r = byte_range(t)
length(r) != 5 && return false
buf = unsafe_textbuf(ps.stream)
buf[r[1]] == UInt8('m') && buf[r[2]] == UInt8('a') &&
buf[r[3]] == UInt8('t') && buf[r[4]] == UInt8('c') &&
buf[r[5]] == UInt8('h')
end

# Return true if the next word (or word pair) is reserved, introducing a
# syntactic structure.
function peek_initial_reserved_words(ps::ParseState)
k = peek(ps)
if is_initial_reserved_word(ps, k)
return true
elseif is_match_identifier(ps)
# `match` is only a reserved word when followed by an expression that
# could be a valid scrutinee. When followed by certain tokens it's
# treated as an identifier for backwards compatibility:
# match(x, y) - function call (no whitespace before `(`)
# match[i] - indexing (no whitespace before `[`)
# foo(match) - match as argument
# match = 1 - assignment
# match::T - type assertion
# for match in x - loop variable
# match.field - field access
# match => v - pair
# match === x - comparison (binary operators)
# function match end - function definition (match followed by end)
t2 = peek_token(ps, 2)
k2 = kind(t2)
# Not a match statement if followed by closing tokens, assignment, newline, etc.
# A newline after match means it's a standalone expression (identifier)
if k2 in KSet") ] } , ; end EndMarker NewlineWs"
return false
end
# Not a match statement if followed by `(` or `[` without whitespace
if k2 in KSet"( [" && !preceding_whitespace(t2)
return false
end
# Special case: `match?` (exception-catching match) - ? without whitespace
# is part of the keyword, so match IS a reserved word
if k2 == K"?" && !preceding_whitespace(t2)
return true
end
# Not a match statement if followed by operators that can only be binary
# (not unary). Unary operators like `+`, `-`, `!`, `~` can start an expression.
if is_operator(k2)
# These can be unary prefix operators, so they CAN start an expression
# <: >: + - ! ~ ¬ √ ∛ ∜ ⋆ ± ∓ $ & :
# Note: `:` is for quoting symbols like `:foo`
# Note: `::` is NOT included - it's a type annotation operator
if k2 in KSet"<: >: + - ! ~ ¬ √ ∛ ∜ ⋆ ± ∓ $ & :"
return true
end
# All other operators are binary-only, so match is not a keyword
return false
end
# Assignment, field access, pair, iteration keywords, type annotation
if k2 in KSet"= . => :: in ∈"
return false
end
# Otherwise, match is a keyword
return true
elseif is_contextual_keyword(k)
k2 = peek(ps, 2, skip_newlines=false)
return (k == K"mutable" && k2 == K"struct") ||
Expand Down Expand Up @@ -1411,7 +1480,8 @@ function parse_decl_with_initial_ex(ps::ParseState, mark)
parse_where(ps, parse_call)
emit(ps, mark, K"::", INFIX_FLAG)
end
if peek(ps) == K"->"
# In match pattern context, -> is a delimiter, not anonymous function syntax
if peek(ps) == K"->" && !ps.match_pattern
kb = peek_behind(ps).kind
if kb == K"tuple"
# (x,y) -> z
Expand Down Expand Up @@ -1769,6 +1839,11 @@ function parse_call_chain(ps::ParseState, mark, is_macrocall=false)
# f'ᵀ ==> (call-post f 'ᵀ)
bump(ps, remap_kind=K"Identifier")
emit(ps, mark, K"call", POSTFIX_OP_FLAG)
elseif k == K"?" && !preceding_whitespace(t)
# Postfix ? for declared exceptions - exception forwarding/unwrapping
# expr? ==> (postfix-? expr)
bump(ps, TRIVIA_FLAG)
emit(ps, mark, K"postfix-?")
elseif k == K"{"
processing_macro_name = maybe_parsed_macro_name(
ps, processing_macro_name, last_identifier_orig_kind, mark)
Expand Down Expand Up @@ -2046,6 +2121,8 @@ function parse_resword(ps::ParseState)
emit(ps, mark, K"primitive")
elseif word == K"try"
parse_try(ps)
elseif word == K"Identifier" && is_match_identifier(ps)
parse_match(ps)
elseif word == K"return"
bump(ps, TRIVIA_FLAG)
k = peek(ps)
Expand Down Expand Up @@ -2412,6 +2489,128 @@ function parse_catch(ps::ParseState)
emit(ps, mark, K"catch")
end

# Parse match expression
#
# match x
# 1 -> :one
# 2 -> :two
# _ -> :other
# end
#
# ==> (match x (matcharm 1 :one) (matcharm 2 :two) (matcharm _ :other))
#
# match (a, b)
# (1, x) | (x, 1) -> x + 1
# _ -> 0
# end
#
# ==> (match (tuple a b) (matcharm (call-i (tuple 1 x) | (tuple x 1)) (call-i x + 1)) (matcharm _ 0))
#
# With if guards:
# match val
# (a, b) if sin(b) == 0. -> a
# (a, b) -> b
# end
#
# ==> (match val (matcharm (guard (tuple a b) (call-i sin(b) == 0.)) a) (matcharm (tuple a b) b))
#
# Inline match-destructuring:
# match (a, b) = val
#
# ==> (match-assign (tuple a b) val)
function parse_match(ps::ParseState)
mark = position(ps)
bump(ps, TRIVIA_FLAG) # consume 'match'

# Check for ? immediately after match (no whitespace) for exception-catching mode
is_exception_mode = false
if peek(ps) == K"?" && !preceding_whitespace(peek_token(ps))
bump(ps, TRIVIA_FLAG) # consume '?' as trivia
is_exception_mode = true
end

# Parse the first expression (could be scrutinee or pattern for inline destructuring)
# Use parse_comma which doesn't include assignment, so we can detect = for match-assign
let ps = with_match_pattern(ps)
parse_comma(ps)
end
# Check for inline match-destructuring: match pattern = expr
if peek(ps) == K"="
bump(ps, TRIVIA_FLAG) # consume '='
parse_eq(ps) # parse the value expression
emit(ps, mark, K"match-assign")
return
end
# Otherwise, this is a full match statement - the first expression is the scrutinee
# Expect newline or semicolon after scrutinee
k = peek(ps)
if k in KSet"NewlineWs ;"
bump(ps, TRIVIA_FLAG)
elseif k != K"end"
recover(is_closer_or_newline, ps, TRIVIA_FLAG,
error="expected newline or `;` after match expression")
end
# Parse match arms until 'end'
n_arms = 0
while peek(ps) != K"end" && peek(ps) != K"EndMarker"
bump_trivia(ps)
if peek(ps) in KSet"end EndMarker"
break
end
arm_mark = position(ps)
# Parse pattern with match_pattern context (disables -> as anon function)
let ps = with_match_pattern(ps)
parse_eq(ps)
end
# Check for if guard: pattern if condition -> body
if peek(ps) == K"if"
guard_mark = position(ps)
bump(ps, TRIVIA_FLAG) # consume 'if'
# Parse guard condition (up to ->)
let ps = with_match_pattern(ps)
parse_cond(ps)
end
# Emit guard node wrapping pattern and condition
emit(ps, arm_mark, K"guard")
end
# Expect and consume ->
if peek(ps) == K"->"
bump(ps, TRIVIA_FLAG)
else
recover((ps, k) -> k in KSet"-> NewlineWs end", ps, TRIVIA_FLAG,
error="expected `->` in match arm")
if peek(ps) == K"->"
bump(ps, TRIVIA_FLAG)
end
end
# Parse body expression
k = peek(ps)
if k in KSet"NewlineWs ;" || k == K"end"
# Empty body - emit nothing, body is implicitly nothing
bump_invisible(ps, K"Placeholder")
else
parse_eq(ps)
end
emit(ps, arm_mark, K"matcharm")
n_arms += 1
# Consume trailing newline/semicolon
k = peek(ps)
if k in KSet"NewlineWs ;"
bump(ps, TRIVIA_FLAG)
end
end
if n_arms == 0
bump_invisible(ps, K"error", TRIVIA_FLAG,
error="match requires at least one arm")
end
bump_closing_token(ps, K"end")
if is_exception_mode
emit(ps, mark, K"match", EXCEPT_FLAG)
else
emit(ps, mark, K"match")
end
end

# flisp: parse-do
function parse_do(ps::ParseState)
mark = position(ps)
Expand Down
25 changes: 25 additions & 0 deletions JuliaSyntax/test/parser.jl
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,31 @@ tests = [
"try x finally y catch e z end" => "(try (block x) (finally (block y)) (catch e (block z)))"
"try x end" => "(try (block x) (error-t))"
],
JuliaSyntax.parse_match => [
# Basic match
"match x\n 1 -> :one\n 2 -> :two\n _ -> :other\n end" =>
"(match x (matcharm 1 (quote-: one)) (matcharm 2 (quote-: two)) (matcharm _ (quote-: other)))"
# Single arm
"match x; _ -> 0 end" => "(match x (matcharm _ 0))"
# Alternation with |
"match x\n (1, a) | (a, 1) -> a\n end" =>
"(match x (matcharm (call-i (tuple-p 1 a) | (tuple-p a 1)) a))"
# Type constraint
"match x\n n::Int -> n * 2\n end" =>
"(match x (matcharm (::-i n Int) (call-i n * 2)))"
# Value escaping with $
"match x\n \$zero -> :zero\n n -> n\n end" =>
"(match x (matcharm (\$ zero) (quote-: zero)) (matcharm n n))"
# Complex scrutinee
"match f(x, y)\n a -> a\n end" =>
"(match (call f x y) (matcharm a a))"
# Empty body
"match x\n 1 ->\n 2 -> :two\n end" =>
"(match x (matcharm 1 □) (matcharm 2 (quote-: two)))"
# Call pattern
"match x\n Some(y) -> y\n None() -> 0\n end" =>
"(match x (matcharm (call Some y) y) (matcharm (call None) 0))"
],
JuliaSyntax.parse_imports => [
"import A as B: x" => "(import (: (error (as (importpath A) B)) (importpath x)))"
"import A, y" => "(import (importpath A) (importpath y))"
Expand Down
16 changes: 16 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Julia v1.14 Release Notes
New language features
---------------------

- New `match` expression for pattern matching. The `match` statement allows matching values against
patterns with support for literals, wildcards (`_`), variable capture, type constraints (`::T`),
tuple destructuring, alternation (`|`), value escaping (`$`), and constructor patterns.
A `MatchError` is thrown if no pattern matches.
```julia
match x
0 -> "zero"
n::Int -> "integer: $n"
(a, b) -> "tuple: $a, $b"
_ -> "other"
end
```

- It is now possible to control which version of the Julia syntax will be used to parse a package by setting the
`compat.julia` or `syntax.julia_version` key in Project.toml. This feature is similar to the notion of "editions"
in other language ecosystems and will allow non-breaking evolution of Julia syntax in future versions.
Expand All @@ -27,6 +40,9 @@ Build system changes
New library functions
---------------------

- New `MatchError` exception type, thrown when a `match` expression fails to find a matching pattern.
- New `@match` macro providing pattern matching via a `begin...end` block syntax.

New library features
--------------------

Expand Down
Loading
Loading