match

package module
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 2, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

README

match

Go Reference

match is a minimal, high-performance generic path router for Go. It maps slash-separated route patterns to values you provide, then returns the matched value and any captured parameters.

It is intentionally narrower than an HTTP framework. It does not know about methods, middleware, redirects, request objects, URL decoding, or path cleaning. That makes it useful anywhere a path-like string needs to resolve to typed application data: HTTP handler lookup, command dispatch, API route tables, asset paths, virtual filesystems, or nested routers.

Key properties:

  • The zero value of Router[T] is ready to use.
  • Routes can store any Go value: handlers, metadata, enum-like strings, or your own structs.
  • Matching is deterministic. Literal routes beat dynamic routes, more-specific dynamic segments beat less-specific ones, and catch-all routes are considered last.
  • Invalid, duplicate, and ambiguous route definitions are rejected at insertion time.
  • Parameter storage is allocation-conscious: up to four captures are stored inline, and MatchInto / MatchPrefixInto let hot paths reuse storage.
  • Clone can copy a route table before extending it independently.
  • After registration, a router can be shared by multiple goroutines for matching.

Install

go get github.com/ryanfowler/match

match requires Go 1.23 or newer.

Quick Start

package main

import (
	"fmt"

	"github.com/ryanfowler/match"
)

func main() {
	var router match.Router[string]

	router.Insert("/posts/{year}/{slug}", "post")
	router.Insert("/static/{*path}", "asset")

	value, params, ok := router.Match("/posts/2026/route-grammar")
	if !ok {
		return
	}

	fmt.Println(value)              // "post"
	fmt.Println(params.Get("year")) // "2026"
	fmt.Println(params.Get("slug")) // "route-grammar"
}

The router's zero value is ready to use. The value can be any Go type:

type Handler struct {
	Name string
}

var router match.Router[Handler]
router.Insert("/health", Handler{Name: "healthcheck"})

API Overview

Use Insert when route definitions are trusted and should fail fast:

router.Insert("/users/{id}", "user")

Use TryInsert when routes come from configuration, plugins, or other input that should produce a regular error:

if err := router.TryInsert("/users/{id}", "user"); err != nil {
	return err
}

Use Match to look up a path. The path is matched exactly as provided; match does not clean paths, decode escapes, or add a leading slash:

value, params, ok := router.Match("/users/42")

Use MatchInto when matching in a hot path and you want to reuse parameter storage:

buf := match.NewParams(4)

value, ok := router.MatchInto("/users/42", &buf)
_, _, _ = value, buf, ok

Use MatchPrefix when a route should match the front of a path and return the remaining path for nested dispatch or mounting:

router.Insert("/api/{version}", "api")

got, ok := router.MatchPrefix("/api/v1/users/42")
// got.Value == "api"
// got.Params.Get("version") == "v1"
// got.Rest == "/users/42"
// ok == true

Use MatchPrefixInto to reuse parameter storage for prefix matches.

After routes are registered, a router may be used by multiple goroutines for matching. If routes are inserted while other goroutines are using the router, synchronize access around the router.

Path Semantics

match treats routes and paths as plain strings with / as the segment separator. It does not normalize either side before matching:

  • Absolute and relative paths are distinct: /users/{id} does not match users/42.
  • Empty segments are significant: /a//b is different from /a/b.
  • Trailing slashes are significant: /a/ is different from /a.
  • Escaped URL bytes are not decoded, and . / .. segments are not cleaned.

This keeps the package independent from any specific transport. If you are matching net/http requests, apply whatever URL or path normalization your application wants before calling Match.

Route Grammar

Routes are slash-separated patterns made from literal text, named parameters, and catch-all parameters. A route does not have to start with /, but most HTTP path-style routes do.

Syntax Meaning
/about Matches the literal path /about.
/{page} Captures one non-empty path segment as page.
/files/{name}.json Captures a parameter with a literal suffix in the same segment.
/user_{id} Captures a parameter with a literal prefix in the same segment.
/static/{*path} Captures the non-empty remainder of the path, including slashes.
/{{/x/}} Matches literal braces: {{ is { and }} is }.

Rules to keep in mind:

  • Parameter names must be non-empty.
  • Parameter names cannot contain /.
  • Each path segment may contain at most one parameter.
  • * is only valid at the start of a catch-all parameter, as in {*path}.
  • Catch-all parameters must be the final token in the route.
  • Parameters and catch-all parameters capture non-empty text.

This is invalid because both parameters are in the same path segment:

err := router.TryInsert("/{first}-{second}", value)
// errors.Is(err, match.ErrInvalidParamSegment) == true

Catch-all parameters can include a literal prefix in the final segment. The captured value starts after that prefix:

router.Insert("/static/prefix-{*path}", value)

_, params, ok := router.Match("/static/prefix-css/site.css")
// params.Get("path") == "css/site.css"
// ok == true

Matching Behavior

When more than one route could match, match chooses the most specific route: literal segments beat parameter segments, parameter segments with more literal text are tried first, and catch-all routes are considered last.

router.Insert("/posts/{year}/{slug}", "post")
router.Insert("/posts/{year}/index", "index")

value, _, _ := router.Match("/posts/2026/index")
// value == "index"

Prefix matching uses the same route grammar but chooses the route that consumes the most path. A route registered as / matches the root prefix of any absolute path, and Rest is / when the match consumes the full path:

router.Insert("/api", "api")
router.Insert("/api/v1", "v1")

got, _ := router.MatchPrefix("/api/v1/users")
// got.Value == "v1"
// got.Rest == "/users"

Parameters are returned in the order they appear in the matched route:

router.Insert("/teams/{team}/members/{member}", "member")

_, params, _ := router.Match("/teams/core/members/ana")

params.At(0) // match.Param{Key: "team", Val: "core"}
params.At(1) // match.Param{Key: "member", Val: "ana"}

Working With Params

Params is an opaque value type. Use its methods instead of depending on its internal representation:

value, params, ok := router.Match("/posts/2026/route-grammar")
_, _ = value, ok

year := params.Get("year")

slug, found := params.TryGet("slug")
_, _ = year, slug
_, _ = found

for i := 0; i < params.Len(); i++ {
	param := params.At(i)
	_, _ = param.Key, param.Val
}

for key, val := range params.Seq() {
	_, _ = key, val
}

merged := match.Merge(params, match.ParamsOf(match.Param{Key: "source", Val: "cache"}))
_ = merged

snapshot := params.All()
_ = snapshot

Match stores up to four parameters inline and allocates only when more storage is needed. MatchInto lets callers reuse a Params buffer across matches:

params := match.NewParams(8)

for _, path := range paths {
	value, ok := router.MatchInto(path, &params)
	_, _, _ = value, params, ok
}

Insert Errors and Conflicts

TryInsert returns an error when a route is invalid, duplicated, or ambiguous. Insert panics on those same errors, which is convenient for hard-coded route tables that should fail during startup.

Invalid route syntax is reported with sentinel errors:

err := router.TryInsert("/src/{*filepath}x", value)
// errors.Is(err, match.ErrInvalidCatchAll) == true

Duplicate and ambiguous routes return *match.ConflictError. Parameter names do not make otherwise identical routes distinct:

var router match.Router[string]

router.Insert("/x/{id}/bar", "id")
err := router.TryInsert("/x/{name}/bar", "name")

var conflict *match.ConflictError
if errors.As(err, &conflict) {
	fmt.Println(conflict.Route) // "/x/{name}/bar"
	fmt.Println(conflict.With)  // "/x/{id}/bar"
}

Dynamic routes also conflict when the same path could select either route, such as /user_{name} and /user_{id}.

DNS Hostname Matching

The module also includes github.com/ryanfowler/match/dns, a sub-package for DNS-style hostname matching with the same generic value storage and reusable parameter capture model:

package main

import (
	"fmt"

	"github.com/ryanfowler/match/dns"
)

func main() {
	var router dns.Router[string]

	router.Insert("example.com", "apex")
	router.Insert("{tenant}.example.com", "tenant")

	value, params, ok := router.Match("api.example.com.")
	if !ok {
		return
	}

	fmt.Println(value)                // "tenant"
	fmt.Println(params.Get("tenant")) // "api"
}

DNS patterns are dot-separated labels matched right-to-left. Literal labels are ASCII case-insensitive, and a single trailing root dot is ignored, so Example.COM and example.com. are equivalent. The package does not parse host:port strings, perform IDNA conversion, or normalize Unicode.

The DNS grammar mirrors the path router where it fits DNS labels:

Syntax Meaning
example.com Matches the literal hostname.
{tenant}.example.com Captures one non-empty label as tenant.
api-{region}.example.com Captures part of one label.
{*subdomain}.example.com Captures one or more leading labels, such as a.b.
{{literal}}.example.com Matches literal braces.

Use MatchSuffix for zone-style dispatch:

router.Insert("example.com", "zone")

got, ok := router.MatchSuffix("api.us.example.com")
// got.Value == "zone"
// got.Prefix == "api.us"
// ok == true

Internals

For a deeper implementation-level architecture overview, see DESIGN.md.

Routes are parsed once during insertion. The parser turns a route string into tokens, splits those tokens into segment patterns, records capture names in route order, and builds a normalized route shape used to detect duplicates even when parameter names differ.

The matcher is a segment trie. Each node can have static edges, parameter edges, catch-all edges, and an optional route value. Static edges are tried first. Parameter edges are sorted by specificity, with more literal text before less literal text, so /user-{id} is preferred over /{id} for "/user-42". Catch-all edges are checked after static and parameter edges. Nodes with many static children add a small lookup map while still preserving compact storage for small route tables.

TryInsert also maintains a conflict index. Dynamic routes are grouped by segment count and first definitely-static segment, with separate tracking for catch-all routes. This catches ambiguous definitions before they can make match results depend on insertion order. For example, /x/{id}/bar conflicts with /x/{name}/bar, while /files/{name}.json/a and /files/report.{ext}/b can coexist because later segments disambiguate them.

Parameters are collected after the winning route is selected, using the canonical route entry's capture names. Params stores up to four captures inline and grows to a slice only when needed. MatchInto and MatchPrefixInto reset and reuse a caller-provided *Params, which avoids heap allocation for common hot-path routing loops.

Prefix matching uses the same trie and route grammar as exact matching. It tracks the best whole-segment prefix while walking the tree, chooses the route that consumes the most path, and returns the remaining path as Rest. When a prefix consumes the full path, Rest is /.

Development

Run the test suite with:

go test ./...

Run benchmarks with:

go test -bench=. -benchmem ./...

License

This project is licensed under the Apache License, Version 2.0. See LICENSE.

Documentation

Overview

Package match provides a minimal, high-performance generic path router.

A Router maps slash-separated route patterns to caller-provided values, then returns the matched value and any captured parameters. It is intentionally narrower than an HTTP framework: it does not know about methods, middleware, redirects, request objects, URL decoding, or path cleaning. That makes it useful anywhere a path-like string needs to resolve to typed application data, such as HTTP handler lookup, command dispatch, API route tables, asset paths, virtual filesystems, or nested routers.

The zero value of Router is ready to use. The stored value can be any Go type, and after routes are registered a Router may be shared by multiple goroutines for matching.

Quick Start

var router match.Router[string]
router.Insert("/posts/{year}/{slug}", "post")
router.Insert("/static/{*path}", "asset")

value, params, ok := router.Match("/posts/2026/route-grammar")
_ = value              // "post"
_ = params.Get("year") // "2026"
_ = params.Get("slug") // "route-grammar"
_ = ok                 // true

Path Semantics

Routes and paths are plain strings with / as the segment separator. match does not normalize either side before matching: absolute and relative paths are distinct, empty segments are significant, trailing slashes are significant, escaped URL bytes are not decoded, and . or .. segments are not cleaned. If you are matching net/http requests, apply whatever URL or path normalization your application wants before calling Match.

The github.com/ryanfowler/match/dns sub-package provides a DNS hostname matcher with the same generic value and Params model for dot-separated, case-insensitive hostname labels.

Route Grammar

Routes are slash-separated patterns made from literal text, named parameters, and catch-all parameters. A route does not have to start with /.

Literal text matches itself. A named parameter is written as {name} and captures one non-empty path segment. A parameter may have literal text before or after it in the same segment, such as /files/{name}.json or /user_{id}. Each path segment may contain at most one parameter.

A catch-all parameter is written as {*name}. It captures the non-empty remainder of the path, including any slashes, and must appear at the end of the route. A catch-all may have a literal prefix in its final segment, such as /static/prefix-{*path}; the captured value starts after that prefix.

Literal braces are escaped by doubling them: {{ matches a literal { and }} matches a literal }. Escaped braces may also appear inside parameter names.

Parameter names must be non-empty. Names cannot contain /, and * is only valid as the first character of a catch-all parameter. Parameters and catch-all parameters capture non-empty text.

API Overview

Insert registers trusted route definitions and panics on invalid, duplicate, or ambiguous routes. TryInsert registers routes from configuration, plugins, or other input that should produce a regular error.

Match looks up an exact path. MatchInto is the same operation using a caller-provided *Params value as reusable storage. MatchPrefix and MatchPrefixInto return the best whole-segment route prefix plus the remaining path, which is useful for mounts and nested dispatch. Rest is / when a prefix match consumes the full path. Clone returns an independent copy of a Router's routing state for cases where a route table needs to be extended without mutating the original.

Matching Behavior and Conflicts

When more than one route could match, match chooses the most specific route: exact literal segments beat parameter segments, parameter segments with more literal text are tried first, and catch-all routes are considered last. Prefix matching uses the same route grammar, but chooses the route that consumes the most path.

TryInsert returns an error for invalid, duplicate, or ambiguous routes. Invalid route syntax is reported with sentinel errors such as ErrInvalidParam, ErrInvalidParamSegment, and ErrInvalidCatchAll. Duplicate and ambiguous routes return *ConflictError. For example, /x/{id}/bar conflicts with /x/{name}/bar because both match the same set of paths. Insert panics on the same errors returned by TryInsert.

Params

Matching returns parameters in route order. Params is an opaque value type; use Len and At to iterate without allocation, Get or TryGet to look up named parameters, Seq for range-over-function iteration, Merge to concatenate parameter sets, and AppendTo or All when a []Param snapshot is needed.

Internals

Routes are parsed once during insertion. The parser turns a route string into tokens, splits those tokens into segment patterns, records capture names in route order, and builds a normalized route shape used to detect duplicates even when parameter names differ.

The matcher is a segment trie. Each node can have static edges, parameter edges, catch-all edges, and an optional route value. Static edges are tried first. Parameter edges are sorted by specificity, with more literal text before less literal text, so /user-{id} is preferred over /{id} for "/user-42". Catch-all edges are checked after static and parameter edges. Nodes with many static children add a small lookup map while preserving compact storage for small route tables.

TryInsert also maintains a conflict index. Dynamic routes are grouped by segment count and first definitely-static segment, with separate tracking for catch-all routes. This catches ambiguous definitions before they can make match results depend on insertion order.

Parameters are collected after the winning route is selected, using the canonical route entry's capture names. Params stores up to four captures inline and grows to a slice only when needed. MatchInto and MatchPrefixInto reset and reuse a caller-provided *Params value, which avoids heap allocation for common hot-path routing loops.

Callers that insert routes while other goroutines use the router must synchronize access.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidParamSegment reports a route segment that contains more than
	// one parameter.
	ErrInvalidParamSegment = errors.New("only one parameter is allowed per path segment")

	// ErrInvalidParam reports malformed parameter syntax or an invalid
	// parameter name.
	ErrInvalidParam = errors.New("parameters must be registered with a valid name")

	// ErrInvalidCatchAll reports a catch-all parameter that is not the final
	// token in its route.
	ErrInvalidCatchAll = errors.New("catch-all parameters are only allowed at the end of a route")
)

Functions

This section is empty.

Types

type ConflictError

type ConflictError struct {
	// Route is the route that failed to insert.
	Route string

	// With is the previously registered route that conflicts with Route.
	With string
}

ConflictError reports a route that cannot be inserted because it overlaps an already registered route.

func (*ConflictError) Error

func (e *ConflictError) Error() string

Error returns a human-readable description of the route conflict.

type Param

type Param struct {
	// Key is the parameter name from the matched route.
	Key string

	// Val is the substring captured from the matched path.
	Val string
}

Param is one captured route parameter.

type Params

type Params struct {
	// contains filtered or unexported fields
}

Params stores captured route parameters in route order.

Params is an opaque value type. Use Len and At to inspect captures without allocation, Get or TryGet to look up a named capture, and AppendTo or All when a []Param snapshot is needed. Up to four captures are stored inline.

func Merge

func Merge(a, b Params) Params

Merge returns a Params value containing a followed by b.

Parameter keys are not deduplicated; when the same key appears in both inputs, the returned Params contains both captures in order.

func NewParams

func NewParams(capacity int) Params

NewParams returns an empty Params value with room for capacity parameters.

It is most useful with Router.MatchInto when callers want to reuse storage across matches. Capacity values of four or less use the inline storage built into Params.

func ParamsOf

func ParamsOf(params ...Param) Params

ParamsOf returns a Params value containing params in the same order.

func (Params) All

func (p Params) All() []Param

All returns a new slice containing the captured parameters.

func (*Params) Append added in v0.3.0

func (p *Params) Append(key, val string)

Append appends a captured parameter to p.

func (Params) AppendTo

func (p Params) AppendTo(dst []Param) []Param

AppendTo appends the captured parameters to dst and returns the extended slice.

func (Params) At

func (p Params) At(i int) Param

At returns the parameter at index i.

It panics if i is outside the range [0, Len()).

func (Params) Get

func (p Params) Get(key string) string

Get returns the value for key, or an empty string when key was not captured.

func (*Params) Grow added in v0.3.0

func (p *Params) Grow(capacity int)

Grow ensures p has enough reusable storage for capacity parameters.

Capacity values of four or less use the inline storage built into Params.

func (Params) Len

func (p Params) Len() int

Len returns the number of captured parameters.

func (*Params) Reset added in v0.3.0

func (p *Params) Reset()

Reset clears p while preserving reusable heap storage.

func (Params) Seq

func (p Params) Seq() iter.Seq2[string, string]

Seq returns an iterator over captured parameter keys and values in route order.

func (Params) TryGet

func (p Params) TryGet(key string) (string, bool)

TryGet returns the value for key and whether key was captured.

type PrefixMatch

type PrefixMatch[T any] struct {
	Value  T
	Params Params
	Rest   string
}

PrefixMatch contains the result of a successful prefix match.

Rest is the remaining path after the matched prefix. It is always "/" when the match consumes the full path.

type Router

type Router[T any] struct {
	// contains filtered or unexported fields
}

Router maps path patterns to caller-provided values.

The zero value is ready to use. After routes are registered, a Router may be used by multiple goroutines for matching. Callers that insert routes while other goroutines use the router must synchronize access.

func (*Router[T]) Clone added in v0.2.0

func (r *Router[T]) Clone() Router[T]

Clone returns a Router containing a deep copy of r's routing state.

Future inserts into the returned Router do not mutate r. Stored values are copied by assignment.

func (*Router[T]) Insert

func (r *Router[T]) Insert(route string, value T)

Insert registers route with value.

It panics with the same errors returned by TryInsert when route is invalid or conflicts with an existing route.

func (*Router[T]) Match

func (r *Router[T]) Match(path string) (T, Params, bool)

Match returns the value and parameters for path.

The boolean result is false when no registered route matches; in that case the value is the zero value of T and the returned Params is empty.

func (*Router[T]) MatchInto

func (r *Router[T]) MatchInto(path string, params *Params) (T, bool)

MatchInto returns the value for path using params as parameter storage.

Params is reset before matching and must be non-nil. Use NewParams to create a reusable Params buffer large enough for the expected number of captures.

func (*Router[T]) MatchPrefix

func (r *Router[T]) MatchPrefix(path string) (PrefixMatch[T], bool)

MatchPrefix returns the value, parameters, and remaining path for the best registered route that matches the front of path.

The boolean result is false when no registered route matches a whole-segment prefix of path. When multiple routes match, the route that consumes the most path wins. A route registered as "/" matches the root prefix of any absolute path.

func (*Router[T]) MatchPrefixInto

func (r *Router[T]) MatchPrefixInto(path string, params *Params) (PrefixMatch[T], bool)

MatchPrefixInto is like MatchPrefix, but uses params as parameter storage.

Params is reset before matching and must be non-nil. Use NewParams to create a reusable Params buffer large enough for the expected number of captures.

func (*Router[T]) TryInsert

func (r *Router[T]) TryInsert(route string, value T) error

TryInsert registers route with value.

It returns an error when route has invalid parameter syntax or when it would conflict with an existing route. Duplicate and ambiguous routes return a *ConflictError.

Directories

Path Synopsis
Package dns provides a minimal, high-performance generic matcher for DNS hostnames.
Package dns provides a minimal, high-performance generic matcher for DNS hostnames.
internal

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL