tsip_parser

Gem Version CI

Ruby binding for the tsip-parser Rust crate. Parses and serializes RFC 3261 §19.1 SIP URIs and §25.1 Addresses (Name <uri>;tag=...) in pure Rust, exposed to Ruby via a magnus native extension.

u = TsipParser::Uri.parse("sip:alice@atlanta.com:5060;transport=tcp")
u.scheme     # => "sip"
u.user       # => "alice"
u.host       # => "atlanta.com"
u.port       # => 5060
u.transport  # => "tcp"
u.to_s       # => "sip:alice@atlanta.com:5060;transport=tcp"

a = TsipParser::Address.parse('"Alice" <sip:alice@atlanta.com>;tag=abc')
a.display_name  # => "Alice"
a.uri.user      # => "alice"
a.tag           # => "abc"

Install

# Gemfile
gem "tsip_parser"

or

gem install tsip_parser

Precompiled binaries are published for the common Ruby-supported platforms (linux-x64-gnu, linux-arm64, darwin-x64, darwin-arm64). Installing on any other platform will compile the Rust extension from source — requires Rust ≥ 1.75 and Ruby ≥ 3.0.

Why

tsip-core (our pure-Ruby SIP stack) ships a byte-scan Uri / Address parser that allocates ~10 intermediate strings per parse. On a hot SIP server those allocations are the single largest GC pressure source. This gem is the same parser, reimplemented in Rust and surfaced with the exact same Ruby API, so existing tsip-core call sites can be swapped module-for-module.

Measured on Ruby 4.0.1, M1 macOS, release build:

endpoint tsip_parser tsip-core speedup
Uri.parse 654k ips 41k ips 16.1×
Address.parse 695k ips 41k ips 16.8×
uri.param("t") 2.07M ips * 125k ips * 16.5×
address.tag 1.99M ips * 139k ips * 14.4×

* parse + single-field lookup, combined.

Reproduce with bundle exec ruby bench/compare.rb and bench/new_apis.rb.

API

TsipParser::Uri

u = TsipParser::Uri.parse(str)        # => TsipParser::Uri, or raises ParseError

u.scheme          # "sip" | "sips" | "tel"
u.user            # String | nil     (pct-decoded)
u.password        # String | nil     (pct-decoded)
u.host            # String           (IPv6 without brackets, e.g. "::1")
u.port            # Integer | nil
u.params          # Hash<String, String>  (insertion order, tsip-core compatible)
u.headers         # Hash<String, String>
u.transport       # "tcp" | "tls" | "udp" | ""   (convenience — same as params["transport"].downcase)
u.aor             # "sip:user@host"                (no port/params/headers)
u.host_port       # "host:port" or "[ipv6]:port"
u.bracket_host    # IPv6 wrapped in [] when needed
u.to_s            # full canonical serialization

# Hot-path helpers (avoid the Hash materialization)
u.param("transport")   # => String | nil  — single Vec lookup in Rust
u.header("subject")    # => String | nil

# Batch parse
TsipParser::Uri.parse_many([str1, str2, ...])  # => Array<Uri>

TsipParser::Address

a = TsipParser::Address.parse(str)

a.display_name    # "Alice Liddell" | nil
a.uri             # TsipParser::Uri | nil
a.params          # Hash<String, String>  (only address-level params: tag / q / expires)
a.tag             # String | nil          (params["tag"])
a.tag = "xyz"     # writes through params
a.to_s

a.param("expires")
TsipParser::Address.parse_many([...])

TsipParser::ParseError

Subclass of ArgumentError. Raised on:

  • empty input with no scheme
  • unterminated [ / " / <
  • invalid UTF-8 after pct-decoding
  • hosts containing forbidden characters (crate 0.1.1 validation)

Because it's an ArgumentError, existing rescue ArgumentError clauses from tsip-core continue to catch it.

Mutation semantics

Params and headers are memoized on first access and returned as mutable Hash objects, same as tsip-core:

u = TsipParser::Uri.parse("sip:a@b")
u.params["transport"] = "tls"   # persists
u.to_s                          # => "sip:a@b;transport=tls"

The fast path (u.to_s without any field access) skips Hash construction and serializes directly from the Rust struct. As soon as params or headers is read or mutated, the Ruby facade switches to the cached Hash for serialization so mutations round-trip correctly.

Rust crate version

Pinned in ext/tsip_parser/Cargo.toml to tsip-parser = "0.2", currently resolved to 0.2.0 (permissive parse, render-side pct-escape). Gem major.minor tracks the crate's major.minor; a gem major bump happens when the crate breaks behavior, not just when it breaks API.

Development

bundle install
bundle exec rake compile     # build the native extension
bundle exec rake test        # 25 tests, tsip-core parity + crate roundtrip subset
bundle exec ruby bench/compare.rb

License

MIT. See LICENSE.

Author

Wonsup Lee (이원섭) — alfonso@team-milestone.io