···8899## [Unreleased]
10101111+### Breaking Changes
1212+1313+- `Atex.NSID` is now a struct (`%Atex.NSID{authority, name, fragment}`). Public
1414+ functions now accept and return structs. You can use `new/1`, `new!/1` or the
1515+ new `~NSID""` for constructing from a NSID string.
1616+1117### Added
12181319- `Atex.Repo` module for building, mutating, signing, serialising, and loading
···1521 efficient processing of large repository exports.
1622- `Atex.XRPC.UnauthedClient` module for running unauthenticated XRPC fetches on
1723 public APIs or PDSes.
1818-- `Atex.NSID.authority_domain/1` for deriving the `_lexicon.<authority>` DNS
1919- name from an NSID.
2024- `Atex.Lexicon.Resolver` module for resolving published lexicons by NSID,
2125 following the
2226 [publication and resolution spec](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution).
+2-2
README.md
···4455## Feature map
6677-- [ ] ATProto strings
77+- [x] ATProto strings
88 - [x] `at://` links
99 - [x] TIDs
1010- - [ ] NSIDs
1010+ - [x] NSIDs
1111- [x] Identity resolution with bi-directional validation and caching.
1212- [x] Macro and codegen for converting Lexicon definitions to runtime schemas
1313 and structs.
+25-12
lib/atex/lexicon.ex
···8585 |> then(&Recase.Enumerable.atomize_keys/1)
8686 |> then(&Atex.Lexicon.Schema.lexicon!/1)
87878888+ nsid = Atex.NSID.new!(lexicon.id)
8989+8890 defs =
8991 lexicon.defs
9090- |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end)
9292+ |> Enum.flat_map(fn {def_name, def} -> def_to_schema(nsid, def_name, def) end)
9193 |> Enum.map(fn
9294 {schema_key, quoted_schema, quoted_type} -> {schema_key, quoted_schema, quoted_type, nil}
9395 x -> x
···135137136138 # - [ ] `t()` type should be the struct in it. (add to non-main structs too?)
137139138138- @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) ::
140140+ @spec def_to_schema(nsid :: Atex.NSID.t(), def_name :: String.t(), lexicon_def :: map()) ::
139141 list(
140142 {
141143 key :: atom(),
···152154153155 defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do
154156 # TODO: record rkey format validator
155155- type_name = Atex.NSID.canonical_name(nsid, to_string(def_name))
157157+ type_name = Atex.NSID.canonical_name(%{nsid | fragment: to_string(def_name)})
156158157159 record =
158160 put_in(record, [:properties, :"$type"], %{
···218220 {key, %{default: default}} -> {key, default}
219221 {key, _field} -> {key, nil}
220222 end)
221221- |> then(&(&1 ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}]))
223223+ |> then(
224224+ &(&1 ++
225225+ [
226226+ {:"$type",
227227+ if(def_name == :main,
228228+ do: Atex.NSID.to_string(nsid),
229229+ else: "#{nsid.authority}.#{nsid.name}##{def_name}"
230230+ )}
231231+ ])
232232+ )
222233223234 enforced_keys =
224235 properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required && &1 != :"$type"))
···477488 defp def_to_schema(nsid, def_name, %{type: "ref", ref: ref}) do
478489 target_module =
479490 nsid
480480- |> Atex.NSID.expand_possible_fragment_shorthand(ref)
491491+ |> Atex.NSID.expand_fragment_shorthand(ref)
481492 |> ref_to_module()
482493483494 {quoted_schema, quoted_type} = field_to_schema(%{type: "ref", ref: ref}, nsid)
···494505 target_modules =
495506 Enum.map(refs, fn ref ->
496507 nsid
497497- |> Atex.NSID.expand_possible_fragment_shorthand(ref)
508508+ |> Atex.NSID.expand_fragment_shorthand(ref)
498509 |> ref_to_module()
499510 end)
500511···530541 [{atomise(def_name), quoted_schema, quoted_type}]
531542 end
532543533533- @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) ::
544544+ @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: Atex.NSID.t()) ::
534545 {quoted_schema :: term(), quoted_typespec :: term()}
535546 defp field_to_schema(%{type: "string"} = field, _nsid) do
536547 fixed_schema = const_or_enum(field)
···655666 defp field_to_schema(%{type: "ref", ref: ref}, nsid) do
656667 {nsid, fragment} =
657668 nsid
658658- |> Atex.NSID.expand_possible_fragment_shorthand(ref)
669669+ |> Atex.NSID.expand_fragment_shorthand(ref)
670670+ |> Atex.NSID.new!()
659671 |> Atex.NSID.to_atom_with_fragment()
660672661673 fragment = Recase.to_snake(fragment)
···678690 |> Enum.map(fn ref ->
679691 {nsid, fragment} =
680692 nsid
681681- |> Atex.NSID.expand_possible_fragment_shorthand(ref)
693693+ |> Atex.NSID.expand_fragment_shorthand(ref)
694694+ |> Atex.NSID.new!()
682695 |> Atex.NSID.to_atom_with_fragment()
683696684697 fragment = Recase.to_snake(fragment)
···731744 defp atomise(x) when is_atom(x), do: x
732745 defp atomise(x) when is_binary(x), do: String.to_atom(x)
733746734734- # Resolves a fully-expanded NSID (possibly with a `#fragment`) to the
747747+ # Resolves a fully-expanded NSID string (possibly with a `#fragment`) to the
735748 # Elixir module atom that `deflexicon` generates for it. When the fragment is
736749 # `main` (or absent), the module is the root NSID module. Otherwise it is a
737750 # PascalCase-named submodule of the root NSID module.
738738- defp ref_to_module(expanded_nsid) do
739739- {nsid_atom, fragment} = Atex.NSID.to_atom_with_fragment(expanded_nsid)
751751+ defp ref_to_module(expanded_nsid) when is_binary(expanded_nsid) do
752752+ {nsid_atom, fragment} = expanded_nsid |> Atex.NSID.new!() |> Atex.NSID.to_atom_with_fragment()
740753741754 if fragment == :main do
742755 nsid_atom
···11defmodule Atex.NSID do
22+ @moduledoc """
33+ Represents an AT Protocol Namespaced Identifier (NSID).
44+55+ An NSID consists of a **domain authority** (reversed domain name, e.g.
66+ `"app.bsky.feed"`) and a **name** segment (e.g. `"post"`), optionally
77+ followed by a **fragment** (e.g. `"view"`), which is a Lexicon-level concept.
88+99+ ## Structure
1010+1111+ - `authority` - the reversed-domain portion, e.g. `"app.bsky.feed"`
1212+ - `name` - the final camelCase segment, e.g. `"post"`
1313+ - `fragment` - optional fragment string, e.g. `"view"` (nil for plain NSIDs)
1414+1515+ ## Construction
1616+1717+ iex> Atex.NSID.new("app.bsky.feed.post")
1818+ {:ok, ~NSID"app.bsky.feed.post"}
1919+2020+ iex> Atex.NSID.new("app.bsky.feed.post#view")
2121+ {:ok, ~NSID"app.bsky.feed.post#view"}
2222+2323+ iex> Atex.NSID.new("invalid")
2424+ {:error, :invalid_nsid}
2525+2626+ iex> Atex.NSID.new!("app.bsky.feed.post")
2727+ ~NSID"app.bsky.feed.post"
2828+2929+ ## Sigil
3030+3131+ Use `~NSID"..."` for convenient literal construction. Raises `ArgumentError`
3232+ at the call site if the string is not a valid NSID.
3333+3434+ import Atex.NSID, only: [sigil_NSID: 2]
3535+ nsid = ~NSID"com.atproto.sync.getRecord"
3636+ """
3737+238 @re ~r/^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)$/
33- # TODO: regex with support for fragment
4394040+ use TypedStruct
4141+4242+ typedstruct do
4343+ field :authority, String.t(), enforce: true
4444+ field :name, String.t(), enforce: true
4545+ field :fragment, String.t() | nil
4646+ end
4747+4848+ @doc """
4949+ Returns the compiled NSID validation regex.
5050+5151+ Useful for embedding into schema validators.
5252+5353+ ## Examples
5454+5555+ iex> Atex.NSID.re()
5656+ ~r/^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)$/
5757+ """
558 @spec re() :: Regex.t()
659 def re, do: @re
7606161+ @doc """
6262+ Returns `true` if the given string is a syntactically valid NSID (without
6363+ fragment), `false` otherwise.
6464+6565+ ## Examples
6666+6767+ iex> Atex.NSID.match?("app.bsky.feed.post")
6868+ true
6969+7070+ iex> Atex.NSID.match?("invalid")
7171+ false
7272+ """
873 @spec match?(String.t()) :: boolean()
974 def match?(value), do: Regex.match?(@re, value)
10751111- @spec to_atom(String.t()) :: atom()
1212- def to_atom(nsid, fully_qualify \\ true) do
1313- nsid
1414- |> String.split(".")
1515- |> Enum.map(&Recase.to_pascal/1)
1616- |> then(fn parts ->
7676+ @doc """
7777+ Parses a string into an `%Atex.NSID{}` struct.
7878+7979+ Accepts an optional `#fragment` suffix. Returns `{:error, :invalid_nsid}` if
8080+ the base NSID portion is not syntactically valid.
8181+8282+ ## Examples
8383+8484+ iex> Atex.NSID.new("app.bsky.feed.post")
8585+ {:ok, ~NSID"app.bsky.feed.post"}
8686+8787+ iex> Atex.NSID.new("app.bsky.feed.post#view")
8888+ {:ok, ~NSID"app.bsky.feed.post#view"}
8989+9090+ iex> Atex.NSID.new("invalid")
9191+ {:error, :invalid_nsid}
9292+ """
9393+ @spec new(String.t()) :: {:ok, t()} | {:error, :invalid_nsid}
9494+ def new(string) when is_binary(string) do
9595+ {base, fragment} = split_fragment(string)
9696+9797+ if match?(base) do
9898+ {authority, name} = split_authority_name(base)
9999+ {:ok, %__MODULE__{authority: authority, name: name, fragment: fragment}}
100100+ else
101101+ {:error, :invalid_nsid}
102102+ end
103103+ end
104104+105105+ @doc """
106106+ Parses a string into an `%Atex.NSID{}` struct, raising `ArgumentError` on
107107+ invalid input.
108108+109109+ ## Examples
110110+111111+ iex> Atex.NSID.new!("app.bsky.feed.post")
112112+ ~NSID"app.bsky.feed.post"
113113+114114+ iex> Atex.NSID.new!("bad")
115115+ ** (ArgumentError) invalid NSID: "bad"
116116+ """
117117+ @spec new!(String.t()) :: t()
118118+ def new!(string) when is_binary(string) do
119119+ case new(string) do
120120+ {:ok, nsid} -> nsid
121121+ {:error, :invalid_nsid} -> raise ArgumentError, "invalid NSID: #{inspect(string)}"
122122+ end
123123+ end
124124+125125+ @doc """
126126+ Sigil for constructing an `%Atex.NSID{}` at runtime, raising `ArgumentError`
127127+ for invalid input.
128128+129129+ ## Examples
130130+131131+ iex> import Atex.NSID, only: [sigil_NSID: 2]
132132+ iex> ~NSID"app.bsky.feed.post"
133133+ ~NSID"app.bsky.feed.post"
134134+ """
135135+ defmacro sigil_NSID({:<<>>, _meta, [string]}, []) when is_binary(string) do
136136+ nsid = Atex.NSID.new!(string)
137137+138138+ quote do
139139+ unquote(Macro.escape(nsid))
140140+ end
141141+ end
142142+143143+ defmacro sigil_NSID({:<<>>, _meta, _parts}, []) do
144144+ quote do
145145+ Atex.NSID.new!(
146146+ unquote({:<<>>, [], [{:"::", [], [{:fragments, [], nil}, {:binary, [], nil}]}]})
147147+ )
148148+ end
149149+ end
150150+151151+ @doc """
152152+ Converts an `%Atex.NSID{}` to its canonical string representation.
153153+154154+ Includes the fragment if present.
155155+156156+ ## Examples
157157+158158+ iex> Atex.NSID.to_string(~NSID"app.bsky.feed.post")
159159+ "app.bsky.feed.post"
160160+161161+ iex> Atex.NSID.to_string(~NSID"app.bsky.feed.post#view")
162162+ "app.bsky.feed.post#view"
163163+ """
164164+ @spec to_string(t()) :: String.t()
165165+ def to_string(%__MODULE__{authority: authority, name: name, fragment: nil}) do
166166+ "#{authority}.#{name}"
167167+ end
168168+169169+ def to_string(%__MODULE__{authority: authority, name: name, fragment: fragment}) do
170170+ "#{authority}.#{name}##{fragment}"
171171+ end
172172+173173+ @doc """
174174+ Converts an `%Atex.NSID{}` to an Elixir module atom.
175175+176176+ The fragment is ignored; only the base NSID segments are used.
177177+178178+ ## Examples
179179+180180+ iex> Atex.NSID.to_atom(~NSID"app.bsky.feed.post")
181181+ App.Bsky.Feed.Post
182182+183183+ iex> Atex.NSID.to_atom(~NSID"app.bsky.feed.post", false)
184184+ :"Elixir.App.Bsky.Feed.Post"
185185+ """
186186+ @spec to_atom(t(), boolean()) :: atom()
187187+ def to_atom(%__MODULE__{authority: authority, name: name}, fully_qualify \\ true) do
188188+ parts =
189189+ "#{authority}.#{name}"
190190+ |> String.split(".")
191191+ |> Enum.map(&Recase.to_pascal/1)
192192+193193+ parts =
17194 if fully_qualify do
18195 ["Elixir" | parts]
19196 else
20197 parts
21198 end
2222- end)
199199+200200+ parts
23201 |> Enum.join(".")
24202 |> String.to_atom()
25203 end
262042727- @spec to_atom_with_fragment(String.t()) :: {atom(), atom()}
2828- def to_atom_with_fragment(nsid) do
2929- if !String.contains?(nsid, "#") do
3030- {to_atom(nsid), :main}
3131- else
3232- [nsid, fragment] = String.split(nsid, "#")
3333- {to_atom(nsid), String.to_atom(fragment)}
3434- end
205205+ @doc """
206206+ Converts an `%Atex.NSID{}` to a `{module_atom, fragment_atom}` pair.
207207+208208+ The fragment defaults to `:main` when absent.
209209+210210+ ## Examples
211211+212212+ iex> Atex.NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post")
213213+ {App.Bsky.Feed.Post, :main}
214214+215215+ iex> Atex.NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post#view")
216216+ {App.Bsky.Feed.Post, :view}
217217+ """
218218+ @spec to_atom_with_fragment(t()) :: {atom(), atom()}
219219+ def to_atom_with_fragment(%__MODULE__{fragment: nil} = nsid) do
220220+ {to_atom(nsid), :main}
35221 end
362223737- @spec expand_possible_fragment_shorthand(String.t(), String.t()) :: String.t()
3838- def expand_possible_fragment_shorthand(main_nsid, possible_fragment) do
3939- if String.starts_with?(possible_fragment, "#") do
4040- main_nsid <> possible_fragment
4141- else
4242- possible_fragment
4343- end
223223+ def to_atom_with_fragment(%__MODULE__{fragment: fragment} = nsid) do
224224+ {to_atom(nsid), String.to_atom(fragment)}
44225 end
452264646- @spec canonical_name(String.t(), String.t()) :: String.t()
4747- def canonical_name(nsid, fragment) do
4848- if fragment == "main" do
4949- nsid
227227+ @doc """
228228+ Expands a possible fragment shorthand relative to this NSID.
229229+230230+ If `ref` starts with `"#"`, it is treated as a fragment shorthand and
231231+ prefixed with the base NSID string. Otherwise `ref` is returned unchanged.
232232+233233+ ## Examples
234234+235235+ iex> Atex.NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "#view")
236236+ "app.bsky.feed.post#view"
237237+238238+ iex> Atex.NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "com.example.other")
239239+ "com.example.other"
240240+ """
241241+ @spec expand_fragment_shorthand(t(), String.t()) :: String.t()
242242+ def expand_fragment_shorthand(%__MODULE__{} = nsid, ref) when is_binary(ref) do
243243+ base = "#{nsid.authority}.#{nsid.name}"
244244+245245+ if String.starts_with?(ref, "#") do
246246+ base <> ref
50247 else
5151- "#{nsid}##{fragment}"
248248+ ref
52249 end
53250 end
5425155252 @doc """
5656- Returns the DNS authority domain for a given NSID, as used for lexicon
5757- resolution via DNS TXT records.
5858-5959- The authority domain is derived by stripping the final name segment from the
6060- NSID, reversing the remaining authority parts, and prepending `_lexicon.`.
253253+ Returns the canonical Lexicon name for this NSID.
612546262- Returns `{:error, :invalid_nsid}` if the input is not a valid NSID.
255255+ Returns the plain NSID string when the fragment is `"main"` or `nil`, and
256256+ `"nsid#fragment"` otherwise.
6325764258 ## Examples
652596666- iex> Atex.NSID.authority_domain("app.bsky.feed.post")
6767- {:ok, "_lexicon.feed.bsky.app"}
260260+ iex> Atex.NSID.canonical_name(~NSID"app.bsky.feed.post")
261261+ "app.bsky.feed.post"
682626969- iex> Atex.NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost")
7070- {:ok, "_lexicon.blogging.lab.dept.university.edu"}
263263+ iex> Atex.NSID.canonical_name(~NSID"app.bsky.feed.post#view")
264264+ "app.bsky.feed.post#view"
712657272- iex> Atex.NSID.authority_domain("invalid")
7373- {:error, :invalid_nsid}
266266+ iex> Atex.NSID.canonical_name(%Atex.NSID{authority: "app.bsky.feed", name: "post", fragment: "main"})
267267+ "app.bsky.feed.post"
74268 """
7575- @spec authority_domain(String.t()) :: {:ok, String.t()} | {:error, :invalid_nsid}
7676- def authority_domain(nsid) do
7777- if match?(nsid) do
7878- authority =
7979- nsid
8080- |> String.split(".")
8181- |> Enum.drop(-1)
8282- |> Enum.reverse()
8383- |> Enum.join(".")
269269+ @spec canonical_name(t()) :: String.t()
270270+ def canonical_name(%__MODULE__{fragment: fragment} = nsid)
271271+ when is_nil(fragment) or fragment == "main" do
272272+ "#{nsid.authority}.#{nsid.name}"
273273+ end
274274+275275+ def canonical_name(%__MODULE__{} = nsid) do
276276+ "#{nsid.authority}.#{nsid.name}##{nsid.fragment}"
277277+ end
278278+279279+ @doc """
280280+ Returns the DNS authority domain for this NSID, as used for lexicon
281281+ resolution via DNS TXT records.
842828585- {:ok, "_lexicon.#{authority}"}
8686- else
8787- {:error, :invalid_nsid}
283283+ The authority domain is derived by reversing the authority segments and
284284+ prepending `_lexicon.`.
285285+286286+ ## Examples
287287+288288+ iex> Atex.NSID.authority_domain(~NSID"app.bsky.feed.post")
289289+ "_lexicon.feed.bsky.app"
290290+291291+ iex> Atex.NSID.authority_domain(~NSID"edu.university.dept.lab.blogging.getBlogPost")
292292+ "_lexicon.blogging.lab.dept.university.edu"
293293+ """
294294+ @spec authority_domain(t()) :: String.t()
295295+ def authority_domain(%__MODULE__{authority: authority}) do
296296+ reversed =
297297+ authority
298298+ |> String.split(".")
299299+ |> Enum.reverse()
300300+ |> Enum.join(".")
301301+302302+ "_lexicon.#{reversed}"
303303+ end
304304+305305+ # --- Private helpers ---
306306+307307+ defp split_fragment(string) do
308308+ case String.split(string, "#", parts: 2) do
309309+ [base, fragment] -> {base, fragment}
310310+ [base] -> {base, nil}
311311+ end
312312+ end
313313+314314+ defp split_authority_name(base) do
315315+ segments = String.split(base, ".")
316316+ name = List.last(segments)
317317+ authority = segments |> Enum.drop(-1) |> Enum.join(".")
318318+ {authority, name}
319319+ end
320320+321321+ defimpl String.Chars do
322322+ def to_string(nsid), do: Atex.NSID.to_string(nsid)
323323+ end
324324+325325+ defimpl Inspect do
326326+ def inspect(%Atex.NSID{} = nsid, _opts) do
327327+ "~NSID\"#{Atex.NSID.to_string(nsid)}\""
88328 end
89329 end
90330end
+188-14
test/atex/nsid_test.exs
···11defmodule Atex.NSIDTest do
22 use ExUnit.Case, async: true
3344+ import Atex.NSID, only: [sigil_NSID: 2]
55+46 alias Atex.NSID
5768 # ---------------------------------------------------------------------------
77- # NSID.authority_domain/1
99+ # NSID.new/1
810 # ---------------------------------------------------------------------------
9111010- describe "NSID.authority_domain/1" do
1111- test "converts a standard 4-part NSID" do
1212- assert {:ok, "_lexicon.feed.bsky.app"} = NSID.authority_domain("app.bsky.feed.post")
1212+ describe "NSID.new/1" do
1313+ test "parses a standard 4-part NSID" do
1414+ assert {:ok, %NSID{authority: "app.bsky.feed", name: "post", fragment: nil}} =
1515+ NSID.new("app.bsky.feed.post")
1316 end
14171515- test "matches the spec example" do
1616- assert {:ok, "_lexicon.blogging.lab.dept.university.edu"} =
1717- NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost")
1818+ test "parses a minimal 3-segment NSID" do
1919+ assert {:ok, %NSID{authority: "com.example", name: "record", fragment: nil}} =
2020+ NSID.new("com.example.record")
1821 end
19222020- test "handles a minimal 3-segment NSID" do
2121- assert {:ok, "_lexicon.example.com"} = NSID.authority_domain("com.example.record")
2323+ test "parses an NSID with a fragment" do
2424+ assert {:ok, %NSID{authority: "app.bsky.feed", name: "post", fragment: "view"}} =
2525+ NSID.new("app.bsky.feed.post#view")
2226 end
23272424- test "handles NSIDs with numbers in segments" do
2525- assert {:ok, "_lexicon.v0.comet.sh"} = NSID.authority_domain("sh.comet.v0.feed")
2828+ test "parses an NSID with numbers in authority segments" do
2929+ assert {:ok, %NSID{authority: "sh.comet.v0", name: "feed", fragment: nil}} =
3030+ NSID.new("sh.comet.v0.feed")
2631 end
27322833 test "returns error for a plain string without dots" do
2929- assert {:error, :invalid_nsid} = NSID.authority_domain("invalid")
3434+ assert {:error, :invalid_nsid} = NSID.new("invalid")
3035 end
31363237 test "returns error for an empty string" do
3333- assert {:error, :invalid_nsid} = NSID.authority_domain("")
3838+ assert {:error, :invalid_nsid} = NSID.new("")
3439 end
35403641 test "returns error for a string with invalid characters" do
3737- assert {:error, :invalid_nsid} = NSID.authority_domain("not.valid!")
4242+ assert {:error, :invalid_nsid} = NSID.new("not.valid!")
4343+ end
4444+4545+ test "returns error for a two-segment string (no name)" do
4646+ assert {:error, :invalid_nsid} = NSID.new("com.example")
4747+ end
4848+ end
4949+5050+ # ---------------------------------------------------------------------------
5151+ # NSID.new!/1
5252+ # ---------------------------------------------------------------------------
5353+5454+ describe "NSID.new!/1" do
5555+ test "returns the struct for a valid NSID" do
5656+ assert %NSID{authority: "app.bsky.feed", name: "post"} = NSID.new!("app.bsky.feed.post")
5757+ end
5858+5959+ test "raises ArgumentError for an invalid NSID" do
6060+ assert_raise ArgumentError, ~r/invalid NSID/, fn ->
6161+ NSID.new!("bad")
6262+ end
6363+ end
6464+ end
6565+6666+ # ---------------------------------------------------------------------------
6767+ # ~NSID sigil
6868+ # ---------------------------------------------------------------------------
6969+7070+ describe "~NSID sigil" do
7171+ test "constructs the correct struct" do
7272+ assert %NSID{authority: "app.bsky.feed", name: "post", fragment: nil} =
7373+ ~NSID"app.bsky.feed.post"
7474+ end
7575+7676+ test "constructs the correct struct with a fragment" do
7777+ assert %NSID{authority: "app.bsky.feed", name: "post", fragment: "view"} =
7878+ ~NSID"app.bsky.feed.post#view"
7979+ end
8080+ end
8181+8282+ # ---------------------------------------------------------------------------
8383+ # String.Chars / to_string
8484+ # ---------------------------------------------------------------------------
8585+8686+ describe "String.Chars" do
8787+ test "renders a plain NSID" do
8888+ assert "app.bsky.feed.post" = to_string(~NSID"app.bsky.feed.post")
8989+ end
9090+9191+ test "renders an NSID with a fragment" do
9292+ assert "app.bsky.feed.post#view" = to_string(~NSID"app.bsky.feed.post#view")
9393+ end
9494+9595+ test "interpolates correctly in a string" do
9696+ nsid = ~NSID"app.bsky.feed.post"
9797+ assert "type: app.bsky.feed.post" = "type: #{nsid}"
9898+ end
9999+ end
100100+101101+ # ---------------------------------------------------------------------------
102102+ # NSID.match?/1
103103+ # ---------------------------------------------------------------------------
104104+105105+ describe "NSID.match?/1" do
106106+ test "returns true for a valid NSID" do
107107+ assert NSID.match?("app.bsky.feed.post")
108108+ end
109109+110110+ test "returns false for an invalid string" do
111111+ refute NSID.match?("invalid")
112112+ end
113113+114114+ test "returns false for a fragment-bearing string" do
115115+ refute NSID.match?("app.bsky.feed.post#view")
116116+ end
117117+ end
118118+119119+ # ---------------------------------------------------------------------------
120120+ # NSID.to_atom/2
121121+ # ---------------------------------------------------------------------------
122122+123123+ describe "NSID.to_atom/2" do
124124+ test "converts to a fully-qualified module atom by default" do
125125+ assert App.Bsky.Feed.Post = NSID.to_atom(~NSID"app.bsky.feed.post")
126126+ end
127127+128128+ test "converts without full qualification when false is passed" do
129129+ result = NSID.to_atom(~NSID"app.bsky.feed.post", false)
130130+ assert result == :"App.Bsky.Feed.Post"
131131+ end
132132+133133+ test "ignores any fragment" do
134134+ assert App.Bsky.Feed.Post = NSID.to_atom(~NSID"app.bsky.feed.post#view")
135135+ end
136136+ end
137137+138138+ # ---------------------------------------------------------------------------
139139+ # NSID.to_atom_with_fragment/1
140140+ # ---------------------------------------------------------------------------
141141+142142+ describe "NSID.to_atom_with_fragment/1" do
143143+ test "returns {module, :main} for a plain NSID" do
144144+ assert {App.Bsky.Feed.Post, :main} = NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post")
145145+ end
146146+147147+ test "returns {module, fragment_atom} when fragment is present" do
148148+ assert {App.Bsky.Feed.Post, :view} =
149149+ NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post#view")
150150+ end
151151+ end
152152+153153+ # ---------------------------------------------------------------------------
154154+ # NSID.expand_fragment_shorthand/2
155155+ # ---------------------------------------------------------------------------
156156+157157+ describe "NSID.expand_fragment_shorthand/2" do
158158+ test "expands a shorthand fragment" do
159159+ assert "app.bsky.feed.post#view" =
160160+ NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "#view")
161161+ end
162162+163163+ test "passes through a fully-qualified NSID unchanged" do
164164+ assert "com.example.other" =
165165+ NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "com.example.other")
166166+ end
167167+168168+ test "passes through a non-fragment string unchanged" do
169169+ assert "main" = NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "main")
170170+ end
171171+ end
172172+173173+ # ---------------------------------------------------------------------------
174174+ # NSID.canonical_name/1
175175+ # ---------------------------------------------------------------------------
176176+177177+ describe "NSID.canonical_name/1" do
178178+ test "returns the plain NSID when fragment is nil" do
179179+ assert "app.bsky.feed.post" = NSID.canonical_name(~NSID"app.bsky.feed.post")
180180+ end
181181+182182+ test "returns the plain NSID when fragment is \"main\"" do
183183+ nsid = %NSID{authority: "app.bsky.feed", name: "post", fragment: "main"}
184184+ assert "app.bsky.feed.post" = NSID.canonical_name(nsid)
185185+ end
186186+187187+ test "returns nsid#fragment when fragment is non-main" do
188188+ assert "app.bsky.feed.post#view" = NSID.canonical_name(~NSID"app.bsky.feed.post#view")
189189+ end
190190+ end
191191+192192+ # ---------------------------------------------------------------------------
193193+ # NSID.authority_domain/1
194194+ # ---------------------------------------------------------------------------
195195+196196+ describe "NSID.authority_domain/1" do
197197+ test "converts a standard 4-part NSID" do
198198+ assert "_lexicon.feed.bsky.app" = NSID.authority_domain(~NSID"app.bsky.feed.post")
199199+ end
200200+201201+ test "matches the spec example" do
202202+ assert "_lexicon.blogging.lab.dept.university.edu" =
203203+ NSID.authority_domain(~NSID"edu.university.dept.lab.blogging.getBlogPost")
204204+ end
205205+206206+ test "handles a minimal 3-segment NSID" do
207207+ assert "_lexicon.example.com" = NSID.authority_domain(~NSID"com.example.record")
208208+ end
209209+210210+ test "handles NSIDs with numbers in segments" do
211211+ assert "_lexicon.v0.comet.sh" = NSID.authority_domain(~NSID"sh.comet.v0.feed")
38212 end
39213 end
40214end