Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support named tuples #117

Merged
merged 23 commits into from
Dec 18, 2024
Merged
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
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 " *
nystrom marked this conversation as resolved.
Show resolved Hide resolved
"`$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
Loading