diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03dad82..f32df3f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: diff --git a/.gitignore b/.gitignore index 45ab88b..4ec801a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ spec_conformance/.cache/ # LuaRocks pack output *.src.rock *.rock + +# Claude Code harness runtime artifacts +.claude/scheduled_tasks.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8ef3f..ab7e790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 11a7eaa..105d8aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/GUIDE.md b/GUIDE.md index b2d83ca..3e3603a 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -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 @@ -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 diff --git a/PLAN.md b/PLAN.md index 19a10a1..b14450e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/README.md b/README.md index 309c8dc..63d1211 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cspell.json b/cspell.json index 7d2843c..3f4c567 100644 --- a/cspell.json +++ b/cspell.json @@ -20,11 +20,13 @@ "HDCP", "hkep", "HKEP", + "ipairs", "ipmx", "IPMX", "jxsv", "glonass", "localmac", + "lnmos", "lpeg", "mpath", "notanumber", @@ -49,6 +51,7 @@ "sendonly", "sendrecv", "sess", + "ssrc", "SDPOKER", "sdpoker", "smpte", diff --git a/parse_sdp-1.3.0-1.rockspec b/parse_sdp-1.4.0-1.rockspec similarity index 98% rename from parse_sdp-1.3.0-1.rockspec rename to parse_sdp-1.4.0-1.rockspec index fff232e..fbabcc8 100644 --- a/parse_sdp-1.3.0-1.rockspec +++ b/parse_sdp-1.4.0-1.rockspec @@ -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 = { diff --git a/parse_sdp/grammar/base.lua b/parse_sdp/grammar/base.lua index 7d24bc0..80e31dd 100644 --- a/parse_sdp/grammar/base.lua +++ b/parse_sdp/grammar/base.lua @@ -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. @@ -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 @@ -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, diff --git a/parse_sdp/init.lua b/parse_sdp/init.lua index 4d516e1..abe3a46 100644 --- a/parse_sdp/init.lua +++ b/parse_sdp/init.lua @@ -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 diff --git a/spec/library_spec.lua b/spec/library_spec.lua index 4c3d088..26fa572 100644 --- a/spec/library_spec.lua +++ b/spec/library_spec.lua @@ -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() @@ -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()