Skip to content

Commit

Permalink
Support named tuples (#117)
Browse files Browse the repository at this point in the history
* Support named tuples
* fix readme
* more tests

---------

Co-authored-by: Neal Gafter <[email protected]>
  • Loading branch information
nystrom and gafter authored Dec 18, 2024
1 parent 74a29a4 commit a39728c
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 14 deletions.
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@ for examples of this and other features.

## Patterns

* `_` matches anything
* `x` (an identifier) matches anything, binds value to the variable `x`
* `_` matches any value
* `x` (an identifier) matches any value and binds it to the variable `x`
* `T(x,y,z)` matches structs of type `T` with fields matching patterns `x,y,z`
* `T(y=1)` matches structs of type `T` whose `y` field equals `1`
* `T(y=p)` matches structs of type `T` whose `y` field matches pattern `p`
* `(;x,y,z)` matches values with fields `x,y,z` binding to variables `x,y,z`
* `(;x=p)` matches values with field `x` matching pattern `p`; does not bind `x`.
* `(;x::T)` matches values with field `x` matching pattern `::T`; also binds the field to `x`
* `[x,y,z]` matches `AbstractArray`s with 3 entries matching `x,y,z`
* `(x,y,z)` matches `Tuple`s with 3 entries matching `x,y,z`
* `[x,y...,z]` matches `AbstractArray`s with at least 2 entries, where `x` matches the first entry, `z` matches the last entry and `y` matches the remaining entries
Expand All @@ -57,8 +60,10 @@ for examples of this and other features.
* `x, if condition end` matches only if `condition` is true (`condition` may use any variables that occur earlier in the pattern eg `(x, y, z where x + y > z)`)
* `x where condition` An alternative form for `x, if condition end`
* `if condition end` A boolean computed pattern. `x && if condition end` is another way of writing `x where condition`.
* Anything else is treated as a constant and tested for equality
* Expressions can be interpolated in as constants via standard interpolation syntax `\$(x)`. Interpolations may use previously bound variables.
* `1` (a literal value) matches that value using `isequal`
* `r"[a-z]*"` (a regular expression) matches strings that match the regular expression
* `1:10` (a constant range) matches values in that range
* Expressions can be interpolated in as constants via standard interpolation syntax `$(x)`. Interpolations may use previously bound variables.

Patterns can be nested arbitrarily.

Expand Down
14 changes: 10 additions & 4 deletions src/Match.jl
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,13 @@ See also
The following syntactic forms can be used in patterns:
* `_` matches anything
* `x` (an identifier) matches anything, binds value to the variable `x`
* `_` matches any value
* `x` (an identifier) matches any value and binds it to the variable `x`
* `T(x,y,z)` matches structs of type `T` with fields matching patterns `x,y,z`
* `T(y=1)` matches structs of type `T` whose `y` field equals `1`
* `T(y=p)` matches structs of type `T` whose `y` field matches pattern `p`
* `(;x,y,z)` matches values with fields `x,y,z` binding to variables `x,y,z`
* `(;x=p)` matches values with field `x` matching pattern `p`; does not bind `x`.
* `(;x::T)` matches values with field `x` matching pattern `::T`; also binds the field to `x`
* `[x,y,z]` matches `AbstractArray`s with 3 entries matching `x,y,z`
* `(x,y,z)` matches `Tuple`s with 3 entries matching `x,y,z`
* `[x,y...,z]` matches `AbstractArray`s with at least 2 entries, where `x` matches the first entry, `z` matches the last entry and `y` matches the remaining entries.
Expand All @@ -64,7 +67,10 @@ The following syntactic forms can be used in patterns:
* `x && y` matches values which match both patterns `x` and `y`
* `x, if condition end` matches only if `condition` is true (`condition` may use any variables that occur earlier in the pattern eg `(x, y, z where x + y > z)`)
* `x where condition` An alternative form for `x, if condition end`
* Anything else is treated as a constant and tested for equality
* `if condition end` A boolean computed pattern. `x && if condition end` is another way of writing `x where condition`.
* `1` (a literal value) matches that value using `isequal`
* `r"[a-z]*"` (a regular expression) matches strings that match the regular expression
* `1:10` (a constant range) matches values in that range
* Expressions can be interpolated in as constants via standard interpolation syntax `\$(x)`. Interpolations may use previously bound variables.
Patterns can be nested arbitrarily.
Expand Down
76 changes: 71 additions & 5 deletions src/binding.jl
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,33 @@ function bind_pattern!(
# disjunction: `(a | b)` where `a` and `b` are patterns.
return bind_pattern!(location, Expr(:(||), source.args[2], source.args[3]), input, binder, assigned)

elseif is_expr(source, :tuple, 1) && is_expr(source.args[1], :parameters)
# named tuples (; x=p, y=q, z)
params = source.args[1]

conjuncts = BoundPattern[]

for param in params.args
field_name, pattern_source = parse_kw_param(location, param, source)

# Check that the field exists.
# We'd like to just test that input isa @NamedTuple($(field_names)...)
# But, NamedTuples are not covariant, so that test would fail if the tuple element is
# not inferred as exactly Any.
guard = Expr(:call, :(Base.hasfield), Expr(:call, :(Base.typeof), input), QuoteNode(field_name))
pattern = bind_where_clause(guard, false, location, binder, assigned)
push!(conjuncts, pattern)

# Bind the field pattern.
fetch = BoundFetchFieldPattern(location, pattern_source, input, field_name, Any)
field_temp = push_pattern!(conjuncts, binder, fetch)
bound_subpattern, assigned = bind_pattern!(
location, pattern_source, field_temp, binder, assigned)
push!(conjuncts, bound_subpattern)
end

pattern = BoundAndPattern(location, source, conjuncts)

elseif is_expr(source, :tuple) || is_expr(source, :vect)
# array or tuple
subpatterns = source.args
Expand Down Expand Up @@ -482,6 +509,28 @@ function push_pattern!(patterns::Vector{BoundPattern}, binder::BinderContext, pa
get_temp(binder, pat)
end

function parse_kw_param(location, param, source)
if is_expr(param, :kw, 2)
# (; x = p)
field_name = param.args[1]
# bind both the pattern p, but not the field name.
pattern_source = param.args[2]
elseif is_expr(param, :(::), 2) && param.args[1] isa Symbol
# (; x::T) -- equivalent to (; x=(x::T))
field_name = param.args[1]
# bind both the field name and pattern `::T`
pattern_source = param
elseif param isa Symbol
# (; x) -- equivalent to (; x=x)
field_name = param
pattern_source = param
else
error("$(location.file):$(location.line): Unexpected named parameter " *
"`$param` in named tuple pattern `$source`.")
end
return (field_name, pattern_source)
end

function split_where(T, location)
type = T
where_clause = nothing
Expand Down Expand Up @@ -544,14 +593,31 @@ function shred_where_clause(
result_type = (inverted == (guard.head == :&&)) ? BoundOrPattern : BoundAndPattern
return result_type(location, guard, BoundPattern[left, right])
else
bound_expression = bind_expression(location, guard, assigned)
fetch = BoundFetchExpressionPattern(bound_expression, nothing, Any)
temp = get_temp(binder, fetch)
test = BoundWhereTestPattern(location, guard, temp, inverted)
return BoundAndPattern(location, guard, BoundPattern[fetch, test])
return bind_where_clause(guard, inverted, location, binder, assigned)
end
end

#
# Compile a shredded where clause.
#
function bind_where_clause(
guard::Any,
inverted::Bool,
location::LineNumberNode,
binder::BinderContext,
assigned::ImmutableDict{Symbol, Symbol})::BoundPattern

@assert !@capture(guard, !g_)
@assert !@capture(guard, g1_ && g2_)
@assert !@capture(guard, g1_ || g2_)

bound_expression = bind_expression(location, guard, assigned)
fetch = BoundFetchExpressionPattern(bound_expression, nothing, Any)
temp = get_temp(binder, fetch)
test = BoundWhereTestPattern(location, guard, temp, inverted)
return BoundAndPattern(location, guard, BoundPattern[fetch, test])
end

#
# getvars
#
Expand Down
43 changes: 43 additions & 0 deletions test/coverage.jl
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,49 @@ end # of automaton
@test_throws ErrorException Match.make_next(node, pattern, binder)
end

@testset "Malformed named tuples" begin
let line = 0, file = @__FILE__
try
line = (@__LINE__) + 2
@eval @match x begin
(; 1, 2, 3, x, y, z) => x
end
@test false
catch ex
@test ex isa LoadError
e = ex.error
@test e isa ErrorException
@test e.msg == "$file:$line: Unexpected named parameter `1` in named tuple pattern `(; 1, 2, 3, x, y, z)`."
end
end
end

@testset "Ensure named tuples work if there is a local typeof" begin
@eval module M1
import Match
import ..Foo
# Do not overload Base.typeof
typeof(x) = Int
f() = Match.@match Foo(1,2) begin
(; x, y) => x+y
end
end
@eval M1.f() == 3
end

@testset "Ensure named tuples work if there is a local hasfield" begin
@eval module M2
import Match
import ..Foo
# Do not overload Base.hasfield
hasfield(x, y) = false
f() = Match.@match Foo(1,2) begin
(; x, y) => x+y
end
end
@eval M2.f() == 3
end

@testset "Abstract types" begin
x = 2
@test (@__match__ x begin
Expand Down
61 changes: 61 additions & 0 deletions test/rematch.jl
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,67 @@ end
end)
end

# match against named tuples
@testset "Named tuples" begin
@test (@match (; x=1, y=2) begin
(; x=1, y=2) => true
end)

@test (@match Foo(1,2) begin
(; x=1, y=2) => true
end)

@test (@match Foo(1,2) begin
(; y=2, x=1) => true
end)

@test (@match Foo(1,2) begin
(; x=1) => true
end)

@test (@match Foo(1,2) begin
(; x::Int) => true
end)

@test (@match Foo(1,2) begin
(; x) => true
end)

# Check deep matching.
@test (@match Foo(Foo(1,2),Foo(3,4)) begin
(; x=(; x=1, y=2), y=(; x=3, y=4)) => true
_ => false
end)

# Check that field names are bound.
@test (@match Foo(1,2) begin
(; x, y) => (x, y)
end) == (1, 2)

# Check that field names are bound for `::` patterns too.
@test (@match Foo(1,2) begin
(; x::Int, y::Int) => (x, y)
end) == (1,2)

# Check that field names are not bound for `=` patterns too.
err = (VERSION < v"1.11-") ? UndefVarError(:x) : UndefVarError(:x, @__MODULE__)
@test_throws err (@match Foo(1,2) begin
(; x=1, y) => (x, y)
end) == (1,2)

# Check that patterns after `=` bind.
@test (@match Foo(1,2) begin
(; x=z, y) => (y, z)
end) == (2,1)

# Check that we don't match if a field does not exist.
@test (@match Foo(1,2) begin
(; x, y, z) => false # No field `z`.
(; x) => true
_ => false
end)
end

@testset "Miscellanea" begin
# match against fiddly symbols (https://github.com/JuliaServices/Match.jl/issues/32)
@test (@match :(@when a < b) begin
Expand Down
14 changes: 14 additions & 0 deletions test/rematch2.jl
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,20 @@ end
end
end

if VERSION >= v"1.8"
@testset "warn for unreachable cases with named tuples" begin
let line = (@__LINE__) + 5
@test_warn(
"$file:$line: Case 2: `(; x, y) =>` is not reachable.",
@eval @match Foo(1, 2) begin
(; x) => 1
(; x, y) => 2
end
)
end
end
end

@testset "assignment to pattern variables are permitted but act locally" begin
@test (@match 1 begin
x where begin
Expand Down

0 comments on commit a39728c

Please sign in to comment.