Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
luarocks install dkjson
luarocks install argparse
luarocks install busted
- run: luarocks make ./parse_sdp-1.3.0-1.rockspec
- run: luarocks make ./parse_sdp-1.4.0-1.rockspec
- run: busted spec/

conformance:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ spec_conformance/.cache/
# LuaRocks pack output
*.src.rock
*.rock

# Claude Code harness runtime artifacts
.claude/scheduled_tasks.lock
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

- _(none yet; 1.3.0 just shipped)_
### Added

- **`sdp.attr_get(block, name)` / `sdp.attrs_get(block, name)`** —
module-level accessors that look up decomposed attributes by name on a
block (`doc.session` or any `doc.media[i]`). `attr_get` returns the
first match (or `nil`); `attrs_get` returns an array of all matches in
document order (empty table when none). Both are nil-safe and mirror
`parse_sdp.grammar.base.params_get` (the inner-`fmtp` accessor) for the
outer `attributes` array, so consumers no longer hand-roll the scan.

## [1.3.0] — 2026-05-26

Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ local doc, err = sdp.parse(text, "st2110") -- parse + validate ST 2110
local doc, err = sdp.parse(text, "ipmx") -- parse + validate IPMX
local doc = sdp.new(table) -- wrap table as doc (no validation)

-- Attribute accessors (module-level; mirror grammar.base.params_get)
local a = sdp.attr_get(block, name) -- first decomposed attr by name, or nil
local as = sdp.attrs_get(block, name) -- all matches in order (empty table if none)

-- doc methods (via metatable)
doc:validate() -- validate as RFC 8866; true or nil, err
doc:validate("st2110") -- validate as ST 2110; true or nil, err
Expand Down
39 changes: 39 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,40 @@ policy["sdp.file.bom-present"] = "off"
local doc, err = sdp.parse(text, "sdp", { policy = policy })
```

#### `sdp.attr_get(block, name)` / `sdp.attrs_get(block, name)`

Look up decomposed attributes by name on a block. A *block* is any table
that carries an `attributes` array — `doc.session` or any `doc.media[i]`
entry. These mirror `parse_sdp.grammar.base.params_get` (the inner-`fmtp`
accessor) for the outer attribute list, so consumers never hand-roll the
scan over the ordered `attributes` array.

- `sdp.attr_get(block, name)` returns the **first** attribute table whose
`name` matches, or `nil` when none is present.
- `sdp.attrs_get(block, name)` returns an **array** of every matching
attribute table, in document order (empty table when none). SDP routinely
carries several `rtpmap` / `fmtp` / `ssrc` / `rtcp-fb` lines on one block,
so reach for `attrs_get` whenever more than one may appear.

Both are nil-safe: a `nil` block (or a block with no `attributes`) yields
`nil` / `{}`. The returned tables are the decomposed shapes described under
[Parsed Table Structure](#parsed-table-structure) — e.g. an `rtpmap` result
carries `payload_type` / `encoding` / `clock_rate`.

```lua
local doc = sdp.parse(text, "st2110")
local m = doc.media[1]

local rtpmap = sdp.attr_get(m, "rtpmap") -- first (or nil)
if rtpmap then print(rtpmap.encoding, rtpmap.clock_rate) end

for _, fb in ipairs(sdp.attrs_get(m, "rtcp-fb")) do -- all matches
print(fb.value)
end

local grp = sdp.attr_get(doc.session, "group") -- session-level lookup
```

---

### Doc methods
Expand Down Expand Up @@ -785,6 +819,11 @@ extensions `infoframe` / `hkep` / `privacy`. Flag-only attributes
`name`. Any other attribute name keeps the forward-compat
`{name, value}` carrier shape.

The `attributes` array is ordered (for byte-faithful round-trip), so look
attributes up by name with
[`sdp.attr_get` / `sdp.attrs_get`](#sdpattr_getblock-name--sdpattrs_getblock-name)
rather than scanning it yourself.

`fmtp.params` and `privacy.params` are **ordered lists** of `{key, value}`
sub-tables (input order preserved for byte-faithful round-trip). Use
`parse_sdp.grammar.base.params_get(params, key)` to look one up; a bare
Expand Down
21 changes: 21 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ The 1.2.1 point release fixed two error-output accuracy bugs:
a `Cg(Cp(), "_addr_pos")` capture (stripped after the Cmt) ferries
the address-start position to `validate_c_address`.

1.3.0 (2026-05-26) added Lua 5.1+ support and the 5.1–5.4 CI matrix.

## 1.4.0 — attribute accessors

Consumer feedback (the lnmos projection layer): the only awkward part of
reading a parsed doc was scanning the ordered `media[i].attributes` array
by name. The library shipped `params_get` for the inner `a=fmtp` params
but nothing for the outer attribute list, so every consumer hand-rolled a
`find_attr` scan.

- [x] `sdp.attr_get(block, name)` — first decomposed attribute of that
name on a block (`doc.session` or `doc.media[i]`), or nil.
- [x] `sdp.attrs_get(block, name)` — all matches in document order (empty
table when none). Earns its place because same-name attributes are
common (`rtpmap` / `fmtp` / `ssrc` / `rtcp-fb`).
- [x] Defined once in `grammar/base.lua` beside `params_get`, inherited
through `extend()`, re-exported on the public module in `init.lua`.
nil-safe; pure read; no round-trip / serialization impact (free
functions, no metatable surface on blocks).
- [ ] **Release slice**: tag `v1.4.0`, upload rockspec, publish.

## Next pass — GUIDE.md Troubleshooting recipes

A field engineer with an unfamiliar `err.id` wants "what does this
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ end
print(doc.session.name)
print(doc.media[1].port)

-- Look attributes up by name (no hand-rolled scan of the ordered array)
local rtpmap = sdp.attr_get(doc.media[1], "rtpmap") -- first match, or nil
local all = sdp.attrs_get(doc.media[1], "rtpmap") -- every match, in order

-- It also has methods
local ok, err = doc:validate("st2110") -- re-validate after mutation
local text = doc:to_sdp() -- → valid SDP string
Expand Down
3 changes: 3 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
"HDCP",
"hkep",
"HKEP",
"ipairs",
"ipmx",
"IPMX",
"jxsv",
"glonass",
"localmac",
"lnmos",
"lpeg",
"mpath",
"notanumber",
Expand All @@ -49,6 +51,7 @@
"sendonly",
"sendrecv",
"sess",
"ssrc",
"SDPOKER",
"sdpoker",
"smpte",
Expand Down
4 changes: 2 additions & 2 deletions parse_sdp-1.3.0-1.rockspec → parse_sdp-1.4.0-1.rockspec
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package = "parse_sdp"
version = "1.3.0-1"
version = "1.4.0-1"

source = {
url = "git+https://github.com/andrewstarks/parse_sdp.git",
tag = "v1.3.0",
tag = "v1.4.0",
}

description = {
Expand Down
28 changes: 28 additions & 0 deletions parse_sdp/grammar/base.lua
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ local function params_get(params, key)
return nil
end

-- Outer-attribute accessors over a block's ordered `attributes` array
-- (a `doc.session` or `doc.media[i]` table). Mirror params_get for the
-- attribute list: attr_get returns the first decomposed attribute whose
-- `name` matches (or nil); attrs_get returns all matches in document
-- order (empty table when none). nil-safe on the block / its attributes.
local function attr_get(block, name)
local attrs = block and block.attributes
if attrs == nil then return nil end
for i = 1, #attrs do
if attrs[i].name == name then return attrs[i] end
end
return nil
end

local function attrs_get(block, name)
local out = {}
local attrs = block and block.attributes
if attrs == nil then return out end
for i = 1, #attrs do
if attrs[i].name == name then out[#out + 1] = attrs[i] end
end
return out
end

-- ── Semantic checks ─────────────────────────────────────────────────────────
-- Cross-section invariants the grammar alone can't express. Each check
-- inspects the captured doc and emits findings via errors.record.
Expand Down Expand Up @@ -1282,6 +1306,8 @@ M.media_section_checks = base_media_section_checks
M.make_validate_doc = make_validate_doc
M.make_document_body = make_document_body
M.params_get = params_get
M.attr_get = attr_get
M.attrs_get = attrs_get
M.is_rtp_block = is_rtp_block
M.is_usb_block = is_usb_block

Expand Down Expand Up @@ -1351,6 +1377,8 @@ function M.extend(parent, overrides)
make_validate_doc = parent.make_validate_doc,
make_document_body = parent.make_document_body,
params_get = parent.params_get,
attr_get = parent.attr_get,
attrs_get = parent.attrs_get,
is_rtp_block = parent.is_rtp_block,
is_usb_block = parent.is_usb_block,
extend = M.extend,
Expand Down
8 changes: 8 additions & 0 deletions parse_sdp/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ M.checks = errors.checks
-- parse() time so typos surface immediately.
M.default_policy = errors.default_policy

--- Look up decomposed attributes by name on a block (a `doc.session` or
-- `doc.media[i]` table). `attr_get` returns the first matching attribute
-- table (or nil); `attrs_get` returns an array of all matches in document
-- order. Both nil-safe. Mirror `parse_sdp.grammar.base.params_get` (the
-- inner-fmtp accessor) for the outer `attributes` array.
M.attr_get = grammar_base.attr_get
M.attrs_get = grammar_base.attrs_get

-- Exposed for spec access; not part of the public contract.
M._errors = errors

Expand Down
94 changes: 94 additions & 0 deletions spec/library_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ local FULL_TEXT_FOR_JSON = table.concat({
"a=rtpmap:96 H264/90000",
}, "\r\n") .. "\r\n"

-- Two payload types on one block → two a=rtpmap lines, plus a session-level
-- attribute and a flag-only attribute. Exercises attr_get (first match) vs
-- attrs_get (all matches) and session- vs media-level lookup.
local MULTI_ATTR_SDP = table.concat({
"v=0",
"o=- 1234567890 1 IN IP4 192.168.1.1",
"s=Multi Attr",
"t=0 0",
"a=tool:probe",
"m=video 5000 RTP/AVP 96 97",
"c=IN IP4 239.100.0.1/64",
"a=rtpmap:96 raw/90000",
"a=rtpmap:97 H264/90000",
"a=recvonly",
}, "\r\n") .. "\r\n"

-- ── 1. Module loads ──────────────────────────────────────────────────────────

describe("library: parse_sdp module loads", function()
Expand Down Expand Up @@ -578,6 +594,84 @@ describe("library: sdp.default_policy()", function()
end)
end)

describe("library: sdp.attr_get() / sdp.attrs_get()", function()
-- NOT-SPEC: library
it("attr_get returns the first matching decomposed attribute", function()
local doc = sdp.parse(MULTI_ATTR_SDP)
local rtpmap = sdp.attr_get(doc.media[1], "rtpmap")
assert.is_table(rtpmap)
assert.equal("rtpmap", rtpmap.name)
assert.equal(96, rtpmap.payload_type)
assert.equal("raw", rtpmap.encoding)
end)

-- NOT-SPEC: library
it("attr_get returns nil when no attribute matches", function()
local doc = sdp.parse(MULTI_ATTR_SDP)
assert.is_nil(sdp.attr_get(doc.media[1], "fmtp"))
end)

-- NOT-SPEC: library
it("attrs_get returns all matches in document order", function()
local doc = sdp.parse(MULTI_ATTR_SDP)
local all = sdp.attrs_get(doc.media[1], "rtpmap")
assert.is_table(all)
assert.equal(2, #all)
assert.equal(96, all[1].payload_type)
assert.equal(97, all[2].payload_type)
end)

-- NOT-SPEC: library
it("attrs_get returns an empty table when no attribute matches", function()
local doc = sdp.parse(MULTI_ATTR_SDP)
local all = sdp.attrs_get(doc.media[1], "fmtp")
assert.is_table(all)
assert.equal(0, #all)
end)

-- NOT-SPEC: library
it("looks up session-level attributes", function()
local doc = sdp.parse(MULTI_ATTR_SDP)
local tool = sdp.attr_get(doc.session, "tool")
assert.is_table(tool)
assert.equal("probe", tool.value)
end)

-- NOT-SPEC: library
it("finds flag-only attributes", function()
local doc = sdp.parse(MULTI_ATTR_SDP)
local flag = sdp.attr_get(doc.media[1], "recvonly")
assert.is_table(flag)
assert.equal("recvonly", flag.name)
end)

-- NOT-SPEC: library
it("is nil-safe: nil block yields nil / empty table", function()
assert.is_nil(sdp.attr_get(nil, "rtpmap"))
local all = sdp.attrs_get(nil, "rtpmap")
assert.is_table(all)
assert.equal(0, #all)
end)

-- NOT-SPEC: library
it("is nil-safe: block without attributes yields nil / empty table", function()
assert.is_nil(sdp.attr_get({}, "rtpmap"))
assert.equal(0, #sdp.attrs_get({}, "rtpmap"))
end)

-- NOT-SPEC: library
it("works on hand-built docs from sdp.new()", function()
local doc = sdp.new({
media = { { attributes = {
{ name = "rtpmap", payload_type = 96 },
{ name = "rtpmap", payload_type = 97 },
} } },
})
assert.equal(96, sdp.attr_get(doc.media[1], "rtpmap").payload_type)
assert.equal(2, #sdp.attrs_get(doc.media[1], "rtpmap"))
end)
end)

describe("library: sdp.parse(text, mode, opts) policy validation", function()
-- NOT-SPEC: library
it("accepts opts.policy with all-known ids", function()
Expand Down
Loading