pubid 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. checksums.yaml +4 -4
  2. data/data/ieee/update_codes.yaml +2 -2
  3. data/data/parg/adobe.json +1 -1
  4. data/data/parg/aiee.json +1 -1
  5. data/data/parg/aiee.parg +99 -9
  6. data/data/parg/amca.json +1 -1
  7. data/data/parg/ansi.json +1 -1
  8. data/data/parg/api.json +1 -1
  9. data/data/parg/ashrae.json +1 -1
  10. data/data/parg/asme.json +1 -1
  11. data/data/parg/astm.json +1 -1
  12. data/data/parg/bipm.json +1 -1
  13. data/data/parg/bsi.json +1 -1
  14. data/data/parg/ccsds.json +1 -1
  15. data/data/parg/cen_cenelec.json +1 -1
  16. data/data/parg/cie.json +1 -1
  17. data/data/parg/csa.json +1 -1
  18. data/data/parg/ecma.json +1 -1
  19. data/data/parg/etsi.json +1 -1
  20. data/data/parg/evs.json +1 -1
  21. data/data/parg/gost.json +1 -1
  22. data/data/parg/iala.json +1 -1
  23. data/data/parg/iana.json +1 -1
  24. data/data/parg/idf.json +1 -1
  25. data/data/parg/iec.json +1 -1
  26. data/data/parg/ieee.json +1 -1
  27. data/data/parg/iho.json +1 -0
  28. data/data/parg/ire.json +1 -1
  29. data/data/parg/ire.parg +51 -12
  30. data/data/parg/isbn.json +1 -1
  31. data/data/parg/iso.json +1 -1
  32. data/data/parg/iso.parg +751 -0
  33. data/data/parg/itu.json +1 -1
  34. data/data/parg/jcgm.json +1 -1
  35. data/data/parg/jis.json +1 -1
  36. data/data/parg/nesc.json +1 -1
  37. data/data/parg/nesc.parg +97 -12
  38. data/data/parg/nist.json +1 -1
  39. data/data/parg/oiml.json +1 -1
  40. data/data/parg/omg.json +1 -1
  41. data/data/parg/parg.json +1 -1
  42. data/data/parg/plateau.json +1 -1
  43. data/data/parg/tgpp.json +1 -1
  44. data/data/parg/un.json +1 -1
  45. data/lib/pubid/adobe/identifier.rb +1 -1
  46. data/lib/pubid/adobe/parser.rb +2 -113
  47. data/lib/pubid/amca/identifiers/base.rb +1 -1
  48. data/lib/pubid/amca/parser.rb +1 -145
  49. data/lib/pubid/ansi/identifier.rb +1 -1
  50. data/lib/pubid/ansi/parser.rb +1 -82
  51. data/lib/pubid/api/builder.rb +32 -1
  52. data/lib/pubid/api/identifier.rb +1 -1
  53. data/lib/pubid/api/parser.rb +1 -131
  54. data/lib/pubid/ashrae/identifiers/base.rb +1 -1
  55. data/lib/pubid/ashrae/parser.rb +3 -710
  56. data/lib/pubid/asme/builder.rb +6 -4
  57. data/lib/pubid/asme/identifier.rb +1 -2
  58. data/lib/pubid/asme/parser.rb +1 -328
  59. data/lib/pubid/astm/builder.rb +12 -3
  60. data/lib/pubid/astm/identifier.rb +1 -2
  61. data/lib/pubid/astm/parser.rb +1 -227
  62. data/lib/pubid/bipm/identifier.rb +8 -1
  63. data/lib/pubid/bipm/parser.rb +2 -224
  64. data/lib/pubid/bsi/builder.rb +2 -4
  65. data/lib/pubid/bsi/parser.rb +1 -666
  66. data/lib/pubid/bsi/single_identifier.rb +38 -1
  67. data/lib/pubid/calconnect/parser.rb +2 -40
  68. data/lib/pubid/ccsds/identifier.rb +1 -1
  69. data/lib/pubid/ccsds/parser.rb +1 -66
  70. data/lib/pubid/cen_cenelec/identifier.rb +16 -1
  71. data/lib/pubid/cen_cenelec/parser.rb +3 -136
  72. data/lib/pubid/cie/identifier.rb +1 -1
  73. data/lib/pubid/cie/parser.rb +3 -378
  74. data/lib/pubid/conformance.rb +1 -1
  75. data/lib/pubid/csa/builder.rb +41 -18
  76. data/lib/pubid/csa/identifier.rb +12 -4
  77. data/lib/pubid/csa/identifiers/amendment.rb +28 -0
  78. data/lib/pubid/csa/identifiers.rb +1 -0
  79. data/lib/pubid/csa/parser.rb +16 -397
  80. data/lib/pubid/doi/parser.rb +2 -37
  81. data/lib/pubid/easc/builder.rb +1 -1
  82. data/lib/pubid/easc/parser.rb +2 -70
  83. data/lib/pubid/ecma/CLAUDE.md +8 -1
  84. data/lib/pubid/ecma/identifier.rb +13 -8
  85. data/lib/pubid/ecma/parser.rb +2 -54
  86. data/lib/pubid/ecma/renderer.rb +3 -7
  87. data/lib/pubid/errors.rb +18 -13
  88. data/lib/pubid/etsi/builder.rb +8 -5
  89. data/lib/pubid/etsi/identifiers/base.rb +1 -1
  90. data/lib/pubid/etsi/parser.rb +1 -132
  91. data/lib/pubid/evs/identifier.rb +7 -0
  92. data/lib/pubid/evs/parser.rb +1 -8
  93. data/lib/pubid/evs.rb +1 -1
  94. data/lib/pubid/gb/identifier.rb +6 -0
  95. data/lib/pubid/gb/parser.rb +2 -51
  96. data/lib/pubid/gost/identifier.rb +9 -1
  97. data/lib/pubid/gost/parser.rb +4 -81
  98. data/lib/pubid/iala/builder.rb +48 -39
  99. data/lib/pubid/iala/identifier.rb +1 -1
  100. data/lib/pubid/iala/parser.rb +2 -121
  101. data/lib/pubid/iana/identifier.rb +1 -1
  102. data/lib/pubid/iana/parser.rb +2 -24
  103. data/lib/pubid/identifier.rb +78 -1
  104. data/lib/pubid/idf/builder.rb +42 -0
  105. data/lib/pubid/idf/identifier.rb +1 -1
  106. data/lib/pubid/idf/parser.rb +1 -137
  107. data/lib/pubid/idf.rb +1 -2
  108. data/lib/pubid/iec/builder.rb +158 -50
  109. data/lib/pubid/iec/identifier.rb +1 -1
  110. data/lib/pubid/iec/parser.rb +3 -430
  111. data/lib/pubid/ieee/CLAUDE.md +2 -0
  112. data/lib/pubid/ieee/aiee/identifier.rb +2 -1
  113. data/lib/pubid/ieee/aiee.rb +0 -1
  114. data/lib/pubid/ieee/builder.rb +177 -30
  115. data/lib/pubid/ieee/components/code.rb +46 -7
  116. data/lib/pubid/ieee/identifiers/base.rb +5 -2
  117. data/lib/pubid/ieee/identifiers/joint_development.rb +7 -3
  118. data/lib/pubid/ieee/ire/identifier.rb +2 -1
  119. data/lib/pubid/ieee/ire.rb +0 -1
  120. data/lib/pubid/ieee/nesc/builder.rb +1 -1
  121. data/lib/pubid/ieee/nesc.rb +0 -1
  122. data/lib/pubid/ieee/parser.rb +1 -1257
  123. data/lib/pubid/ieee/project_renderer.rb +5 -1
  124. data/lib/pubid/ieee/renderer.rb +4 -2
  125. data/lib/pubid/ieee/urn_generator.rb +4 -1
  126. data/lib/pubid/ietf/identifiers/base.rb +17 -1
  127. data/lib/pubid/ietf/parser.rb +2 -47
  128. data/lib/pubid/ietf/renderer.rb +17 -2
  129. data/lib/pubid/iho/CLAUDE.md +98 -0
  130. data/lib/pubid/iho/builder.rb +4 -1
  131. data/lib/pubid/iho/identifiers/base.rb +28 -2
  132. data/lib/pubid/iho/renderer.rb +8 -0
  133. data/lib/pubid/iho/urn_generator.rb +14 -1
  134. data/lib/pubid/iho/urn_parser.rb +45 -32
  135. data/lib/pubid/iho.rb +0 -1
  136. data/lib/pubid/isbn/identifier.rb +1 -1
  137. data/lib/pubid/isbn/parser.rb +2 -31
  138. data/lib/pubid/iso/CLAUDE.md +66 -0
  139. data/lib/pubid/iso/builder.rb +229 -15
  140. data/lib/pubid/iso/identifier.rb +22 -12
  141. data/lib/pubid/iso/identifiers/directives.rb +6 -3
  142. data/lib/pubid/iso/identifiers/guide.rb +6 -3
  143. data/lib/pubid/iso/identifiers/international_workshop_agreement.rb +6 -3
  144. data/lib/pubid/iso/normalizer.rb +5 -4
  145. data/lib/pubid/iso/parser.rb +1 -539
  146. data/lib/pubid/iso/supplement_identifier.rb +6 -3
  147. data/lib/pubid/iso/urn_parser.rb +13 -8
  148. data/lib/pubid/iso/utilities.rb +4 -5
  149. data/lib/pubid/iso.rb +1 -19
  150. data/lib/pubid/itu/builder.rb +36 -20
  151. data/lib/pubid/itu/identifiers/base.rb +15 -2
  152. data/lib/pubid/itu/parser.rb +1 -775
  153. data/lib/pubid/jcgm/builder.rb +35 -0
  154. data/lib/pubid/jcgm/parser.rb +1 -151
  155. data/lib/pubid/jcgm.rb +1 -2
  156. data/lib/pubid/jis/CLAUDE.md +36 -0
  157. data/lib/pubid/jis/builder.rb +30 -41
  158. data/lib/pubid/jis/identifier.rb +12 -2
  159. data/lib/pubid/jis/identifiers/welding_engineering_standard.rb +35 -0
  160. data/lib/pubid/jis/identifiers.rb +2 -0
  161. data/lib/pubid/jis/parser.rb +1 -128
  162. data/lib/pubid/jis/renderer.rb +1 -1
  163. data/lib/pubid/jis/supplement_identifier.rb +5 -5
  164. data/lib/pubid/jis/urn_parser.rb +2 -1
  165. data/lib/pubid/jis.rb +3 -2
  166. data/lib/pubid/nist/builder.rb +5 -1
  167. data/lib/pubid/nist/caster.rb +13 -3
  168. data/lib/pubid/nist/identifiers/base.rb +37 -2
  169. data/lib/pubid/nist/identifiers/circular.rb +2 -2
  170. data/lib/pubid/nist/identifiers/circular_supplement.rb +2 -2
  171. data/lib/pubid/nist/identifiers/commercial_standards_monthly.rb +2 -2
  172. data/lib/pubid/nist/identifiers/crpl_report.rb +2 -2
  173. data/lib/pubid/nist/identifiers/handbook.rb +2 -2
  174. data/lib/pubid/nist/identifiers/internal_report.rb +2 -2
  175. data/lib/pubid/nist/identifiers/miscellaneous_publication.rb +2 -2
  176. data/lib/pubid/nist/identifiers/monograph.rb +2 -2
  177. data/lib/pubid/nist/identifiers/report.rb +2 -2
  178. data/lib/pubid/nist/parser.rb +19 -775
  179. data/lib/pubid/nist/router.rb +4 -1
  180. data/lib/pubid/nist/supplement_identifier.rb +2 -2
  181. data/lib/pubid/oasis/parser.rb +2 -16
  182. data/lib/pubid/ogc/parser.rb +2 -26
  183. data/lib/pubid/oiml/identifiers/certification_system.rb +7 -0
  184. data/lib/pubid/oiml/identifiers/dual_published.rb +2 -2
  185. data/lib/pubid/oiml/parser.rb +1 -303
  186. data/lib/pubid/oiml.rb +1 -2
  187. data/lib/pubid/omg/identifier.rb +6 -1
  188. data/lib/pubid/omg/parser.rb +2 -76
  189. data/lib/pubid/parser.rb +5 -4
  190. data/lib/pubid/parsers/mr_string.rb +1 -1
  191. data/lib/pubid/plateau/parser.rb +1 -66
  192. data/lib/pubid/plateau.rb +1 -2
  193. data/lib/pubid/renderers/directives_renderer.rb +1 -1
  194. data/lib/pubid/renderers/iwa_renderer.rb +1 -1
  195. data/lib/pubid/renderers/publisher_stripper.rb +82 -0
  196. data/lib/pubid/renderers/supplement_renderer.rb +1 -1
  197. data/lib/pubid/renderers.rb +1 -0
  198. data/lib/pubid/sae/parser.rb +1 -48
  199. data/lib/pubid/tgpp/identifier.rb +1 -1
  200. data/lib/pubid/tgpp/parser.rb +2 -59
  201. data/lib/pubid/un/identifier.rb +1 -1
  202. data/lib/pubid/un/parser.rb +1 -18
  203. data/lib/pubid/version.rb +1 -1
  204. data/lib/pubid/w3c/parser.rb +2 -27
  205. data/lib/pubid/xsf/identifier.rb +10 -6
  206. data/lib/pubid/xsf/parser.rb +3 -31
  207. data/lib/pubid/xsf/renderer.rb +2 -4
  208. data/lib/pubid.rb +8 -9
  209. metadata +9 -23
  210. data/lib/pubid/ieee/aiee/parser.rb +0 -130
  211. data/lib/pubid/ieee/ire/parser.rb +0 -109
  212. data/lib/pubid/ieee/nesc/parser.rb +0 -155
  213. data/lib/pubid/iho/parser.rb +0 -68
  214. data/lib/pubid/parser/common_parse_methods.rb +0 -13
  215. data/lib/pubid/parser/common_parse_rules.rb +0 -56
  216. data/lib/pubid/parser/grammar.rb +0 -96
@@ -14,8 +14,12 @@ module Pubid
14
14
 
15
15
  def render(context: nil, **_opts)
16
16
  id = @id
17
+ # Canonical code: a project draft's code is reassembled from
18
+ # parse-tree captures, and the assembled separators are synthetic
19
+ # — the printed face is the uniform spelling (the same document
20
+ # spelled with all dashes or all dots renders identically).
17
21
  body = if id.respond_to?(:code_obj) && id.code_obj
18
- id.code_obj.to_s
22
+ id.code_obj.canonical_to_s
19
23
  else
20
24
  id.number.to_s
21
25
  end
@@ -472,8 +472,10 @@ module Pubid
472
472
  end
473
473
 
474
474
  # Code (number) with draft version for PSI — the mark goes after the
475
- # code, before the draft and the date.
476
- code_part = id.code.to_s
475
+ # code, before the draft and the date. Canonical code: the PSI
476
+ # code is reassembled from parse-tree captures (synthetic
477
+ # separators — see Components::Code#canonical_to_s).
478
+ code_part = id.code.canonical_to_s
477
479
  code_part += mark(id.code_obj&.number, id.code_obj&.prefix,
478
480
  publishers: publishers_of(id))
479
481
  if id.draft_obj
@@ -190,7 +190,10 @@ module Pubid
190
190
 
191
191
  return nil unless identifier.code_obj
192
192
 
193
- identifier.code_obj.to_s
193
+ # The URN is spelling-stable: codes printed with mixed part
194
+ # separators ("7.4-3-2") share one identity with their uniform
195
+ # spelling, so the segment renders canonically, not as printed.
196
+ identifier.code_obj.canonical_to_s
194
197
  end
195
198
 
196
199
  # A multi-designation wrapper (AdoptedStandard, DualPublished,
@@ -64,9 +64,25 @@ module Pubid
64
64
  raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
65
65
  end
66
66
 
67
- parsed = Pubid::Parg::Model.bound(:ietf, identifier)
67
+ parsed = Pubid::Parg::Model.bound(:ietf, strip_prefix(identifier))
68
68
  Builder.build(parsed)
69
69
  end
70
+
71
+ # The external spellings `IETF RFC 8341`, `IETF I-D draft-…`,
72
+ # `IETF Internet-Draft draft-…`, `Internet-Draft draft-…` and
73
+ # `I-D draft-…` name the same document as the bare form. The publisher
74
+ # and the draft label are not part of the identity, so they are dropped
75
+ # before the baked grammar runs, and `to_s(with_publisher:)` renders
76
+ # them again. The lookahead keeps the strip off anything that is not
77
+ # followed by an IETF designation.
78
+ EXTERNAL_PREFIX = /
79
+ \A(?:IETF\s+)?(?:(?:I-D|Internet-Draft)\s+(?=draft-))?
80
+ (?=(?:RFC|BCP|STD|FYI)\s|draft-)
81
+ /x
82
+
83
+ def self.strip_prefix(identifier)
84
+ identifier.sub(EXTERNAL_PREFIX, "")
85
+ end
70
86
  end
71
87
  end
72
88
  end
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "parslet"
4
-
5
3
  module Pubid
6
4
  module Ietf
7
- # Parslet grammar for the three IETF families. The top-level alternation is
5
+ # Grammar for the three IETF families. The top-level alternation is
8
6
  # keyed by leading token, and each alternative captures the raw fields the
9
7
  # Builder needs:
10
8
  # * RFC -> { number: }
@@ -12,50 +10,7 @@ module Pubid
12
10
  # * draft -> { draft_rest: } (everything after "draft-"; the version
13
11
  # split is done in the Builder to keep the digit-tail
14
12
  # heuristic in one place)
15
- class Parser < ::Pubid::Parser::Grammar
16
- rule(:space) { str(" ") }
17
- rule(:digit) { match["0-9"] }
18
- rule(:digits) { digit.repeat(1) }
19
-
20
- # "RFC 2119", and the zero-padded, space-less spelling the RFC editor's
21
- # rfc-index.xml emits ("RFC0001"). See #subseries for why.
22
- rule(:rfc) { str("RFC") >> space.maybe >> digits.as(:number) }
23
-
24
- # "BCP 3" / "STD 66" / "FYI 1".
25
- #
26
- # The space is optional and leading zeros are allowed because
27
- # rfc-index.xml writes the sub-series membership of an RFC in the padded,
28
- # space-less form -- <is-also><doc-id>STD0066</doc-id></is-also> -- and
29
- # relaton builds the STD/BCP/FYI <-> RFC cross-references from exactly
30
- # those elements without normalizing them itself. The Builder strips the
31
- # pad, so the printed form stays the canonical "STD 66".
32
- rule(:subseries) do
33
- (str("BCP") | str("STD") | str("FYI")).as(:series) >>
34
- space.maybe >> digits.as(:number)
35
- end
36
-
37
- # Internet-Draft slug characters: lowercase, digits and the "-", "+", "_"
38
- # separators, plus two historical shapes the published corpus carries --
39
- # a dot inside a topic token ("draft-ietf-pilc-2.5g3g-12") and uppercase
40
- # letters in protocol/organisation names ("draft-chapin-clnp-ISO8473-00").
41
- # 50 of the 166,740 relaton-data-ids identifiers need those two
42
- # characters; nothing else outside [a-z0-9-] occurs in the corpus.
43
- #
44
- # Widening the tail is unambiguous: the top-level alternation is keyed on
45
- # the leading token (RFC / BCP|STD|FYI / draft-), so a wider draft tail
46
- # cannot make another branch match.
47
- #
48
- # The whole remainder after "draft-" is captured; the Builder anchors the
49
- # optional trailing "-NN" version.
50
- rule(:draft_rest) { match['a-zA-Z0-9+_.\-'].repeat(1).as(:draft_rest) }
51
- rule(:draft) { str("draft-") >> draft_rest }
52
-
53
- rule(:identifier) { rfc | subseries | draft }
54
- rule(:root) { identifier }
55
-
56
- def self.parse(input)
57
- new.parse(input)
58
- end
13
+ class Parser
59
14
  end
60
15
  end
61
16
  end
@@ -8,10 +8,25 @@ module Pubid
8
8
  # "RFC 2119"
9
9
  # "BCP 3" / "STD 66" / "FYI 1"
10
10
  # "draft-giuliano-treedn-02" / "draft-giuliano-treedn"
11
+ #
12
+ # The default form carries no publisher, which is how IETF documents are
13
+ # cited. `with_publisher:` names the two forms relaton#49 asks for:
14
+ # true -> "IETF RFC 2119" / "IETF Internet-Draft draft-giuliano-treedn"
15
+ # false -> "RFC 2119" / "Internet-Draft draft-giuliano-treedn"
16
+ # A draft slug says nothing about its type, so both explicit forms label
17
+ # it; the default keeps the bare slug.
11
18
  class Renderer < ::Pubid::Renderers::Base
12
- def render(context: nil, **_opts)
19
+ INTERNET_DRAFT_LABEL = "Internet-Draft"
20
+
21
+ def render(context: nil, with_publisher: nil, **_opts)
13
22
  require_number!(@id)
14
- render_identifier(@id)
23
+ result = render_identifier(@id)
24
+ return result if with_publisher.nil?
25
+
26
+ if @id.is_a?(Identifiers::InternetDraft)
27
+ result = "#{INTERNET_DRAFT_LABEL} #{result}"
28
+ end
29
+ with_publisher ? "#{Identifier::PUBLISHER} #{result}" : result
15
30
  end
16
31
 
17
32
  private
@@ -0,0 +1,98 @@
1
+ # IHO flavor notes
2
+
3
+ IHO (International Hydrographic Organization) identifiers: the
4
+ S/P/M/B/C letter series, their parg-driven parse, and the series
5
+ components introduced for relaton/relaton#407.
6
+
7
+ Read this before you change `lib/pubid/iho/` or `spec/pubid/iho/`. The
8
+ root `CLAUDE.md` keeps the cross-flavor contract that every flavor
9
+ obeys.
10
+
11
+ ## Parsing runs on the parg artifact
12
+
13
+ There is no `parser.rb` and no Parslet anywhere in this flavor: parse
14
+ goes `Pubid::Parg::Backend.parse(:iho, ...)` over the baked artifact
15
+ `data/parg/iho.json`. The artifact is baked from the flavor grammar
16
+ source `grammars/iho.parg` in the pubid-grammar repository (commit
17
+ 840b2b1d "Retire the last live Parslet parsers" ports the original
18
+ 13-rule Parslet grammar byte-identically).
19
+
20
+ **Drift warning:** the artifact on this branch carries the
21
+ relaton/relaton#407 tail rules (year / volume / issue), while the
22
+ checked-in `grammars/iho.parg` in pubid-grammar still has only the
23
+ version tail. Until the updated grammar source lands in pubid-grammar,
24
+ the artifact is ahead of its source and not reproducible from it —
25
+ the same artifact-ahead-of-source state the root notes describe under
26
+ grammar-artifact drift. Do not re-bake from the stale source, and do
27
+ not hand-edit the artifact; land the grammar first.
28
+
29
+ ## Series components (relaton/relaton#407)
30
+
31
+ The B-7, P-1, P-5, P-6 series are continuously updated publications
32
+ whose trailing component is not an a.b.c edition: P-5/P-6 (and B-7)
33
+ are identified by **year**, P-1 (the International Hydrographic
34
+ Review) by **volume** with an optional **issue**. The grammar tail
35
+ picks exactly one discriminator, so only one of `version` / `year` /
36
+ `volume` is ever present, with `issue` only beside a volume:
37
+
38
+ - `IHO P-5 2024` — year is a plain `:string` scalar, 19xx/20xx only
39
+ (`IHO P-5 1850` and the two-segment `IHO B-12 3.0` reject).
40
+ - `IHO P-1 Vol. 30 (2)` — volume/issue scalars; `Issue 2` is an input
41
+ alias whose canonical print is `(2)`.
42
+ - `IHO S-44 5.0.0` — the dotted `version` stays for the edition-bearing
43
+ series and legacy `1.0.0` placeholder rows.
44
+
45
+ All three are ordinary lutaml attributes mapped through the flavor's
46
+ `key_value` block — no converters, no hand-rolled serialization.
47
+
48
+ **Identity semantics:** the components are identity-bearing in `==` /
49
+ `matches?`; a bare reference (`IHO P-5`) leaves all four nil and
50
+ wildcards the series (`spec/pubid/partial_ref_spec.rb` locks the
51
+ omitted-key set). The volume/issue pair is a scalar **cluster**:
52
+ `exclude(:volume)` nils the issue with it, so a reference that omits
53
+ the issue still matches a hit that carries one — the same
54
+ additive-alias shape the base applies to `:year -> :date`. A lone
55
+ scalar `:year` needs no override; the base nils a declared `:year`
56
+ directly.
57
+
58
+ **`all_parts_edition_keys` needs no override:** the default
59
+ `%i[date year edition version]` already covers `:year` and `version`,
60
+ and volume/issue only ride part-less ids, which cannot join
61
+ `AllParts` (`validate_member!` demands a `part`). The audit case in
62
+ `spec/pubid/all_parts_edition_keys_audit_spec.rb` covers the
63
+ version-bearing S-series collapse.
64
+
65
+ **URN conventions:** only the dotted `version` is an untagged
66
+ segment. A year is tagged `y.N` (`urn:iho:p:5:y.2024`); a volume is a
67
+ `vol.N` segment with an optional `issue.N` segment after it
68
+ (`urn:iho:p:1:vol.30:issue.2`). The URN parser walks the tagged
69
+ segments as a run and hands the rebuilt text back through the flavor
70
+ parse, so a URN round-trip exercises the same grammar as a string.
71
+ **The number is not escaped and can hold a colon** (`IHO S-158:100`
72
+ gives `urn:iho:s:158:100`), so the parser joins every untagged segment
73
+ that is not an a.b.c version back into the number. That is why the
74
+ year must carry a tag: with the bare year that pubid 2.0.2 emitted,
75
+ `IHO S-158:2024` and `IHO S-158 2024` both gave `urn:iho:s:158:2024`,
76
+ and no colon number round-tripped at all. A 2.0.2 year URN such as
77
+ `urn:iho:p:5:2024` now decodes as the number `5:2024`; it was emitted
78
+ only by 2.0.2. A tagged segment with an unknown tag raises
79
+ `Pubid::Errors::UrnParseError`.
80
+
81
+ ## Fixtures run through the classify pipeline
82
+
83
+ `spec/fixtures/iho/identifiers/full/identifiers.txt` is the input
84
+ corpus; `rake "validation:classify[iho]"` regenerates `pass/` and
85
+ `fail/` from it (the classifier deletes pass/fail first — never edit
86
+ them by hand, edit `full/` and reclassify). Before the relaton#407
87
+ branch the pass files were hand-curated with **no** `full/` tree, the
88
+ OASIS hazard: a classify run would have wiped 131 entries and rebuilt
89
+ nothing. The classifier's per-flavor class detection has an `iho`
90
+ case, so fail entries land in a named class file.
91
+
92
+ ## relaton-side follow-ups (not this repo)
93
+
94
+ relaton/relaton#407 items 3-4 stay open on the relaton side:
95
+ `Relaton::Iho::Bibliography#pubid_match?`/`version_key` need the new
96
+ component, relaton-cli's `MachineIndex::REFINABLE_KEYS` needs
97
+ `year`/`volume` beside `version`/`edition`, and the pubid pin bumps
98
+ in step with the relaton-data-iho re-key.
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Pubid
4
4
  module Iho
5
- # Build IHO identifier objects from parslet parse output.
5
+ # Build IHO identifier objects from the parg artifact parse tree.
6
6
  class Builder
7
7
  def build(parsed)
8
8
  hash = parsed.is_a?(Array) ? parsed.reduce({}, :merge) : parsed
@@ -15,6 +15,9 @@ module Pubid
15
15
  annex: stringify(hash[:annex]),
16
16
  supplement: stringify(hash[:supplement]),
17
17
  version: stringify(hash[:version]),
18
+ year: stringify(hash[:year]),
19
+ volume: stringify(hash[:volume]),
20
+ issue: stringify(hash[:issue]),
18
21
  }.compact
19
22
 
20
23
  Pubid::Iho.identifier_klass_for_type_letter(type_letter).new(**attrs)
@@ -6,7 +6,7 @@ module Pubid
6
6
  # every concrete IHO identifier (Identifiers::*) descends from it.
7
7
  #
8
8
  # IHO identifiers have the form:
9
- # IHO {S|P|M|B|C}-{number}[ Ap. {appendix}][ Part {part}][ Annex {annex}][ Suppl {supplement}][ {version}]
9
+ # IHO {S|P|M|B|C}-{number}[ Ap. {appendix}][ Part {part}][ Annex {annex}][ Suppl {supplement}][ {version|year|Vol. N [issue]}]
10
10
  #
11
11
  # The leading IHO publisher prefix is optional on input but always
12
12
  # emitted on output.
@@ -25,7 +25,7 @@ module Pubid
25
25
  raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
26
26
  end
27
27
 
28
- parsed = Parser.parse(identifier)
28
+ parsed = Pubid::Parg::Backend.parse(:iho, identifier.strip)
29
29
  Builder.build(parsed)
30
30
  end
31
31
 
@@ -40,6 +40,17 @@ module Pubid
40
40
  attribute :annex, :string
41
41
  attribute :supplement, :string
42
42
  attribute :version, :string
43
+ # Series identity components (relaton#407): the year-identified
44
+ # series (B-7, P-5, P-6) and the volume/issue-identified ones
45
+ # (P-1 and the IHR) carry no a.b.c edition — their trailing
46
+ # component is a year or a "Vol. N [issue]" pair instead. Plain
47
+ # scalars, like every other IHO attribute; the grammar's tail
48
+ # picks exactly one discriminator, so only one of
49
+ # version/year/volume is ever present, with issue only
50
+ # alongside volume.
51
+ attribute :year, :string
52
+ attribute :volume, :string
53
+ attribute :issue, :string
43
54
 
44
55
  # Polymorphic type map for lutaml::Model key_value serialization: maps
45
56
  # each subclass's polymorphic_name to its class name so `from_hash` can
@@ -65,10 +76,25 @@ module Pubid
65
76
  map "annex", to: :annex
66
77
  map "supplement", to: :supplement
67
78
  map "version", to: :version
79
+ map "year", to: :year
80
+ map "volume", to: :volume
81
+ map "issue", to: :issue
68
82
  end
69
83
 
70
84
  # from_hash is the shared polymorphic dispatch on Pubid::Identifier.
71
85
  # IHO_TYPE_MAP remains as the key_value polymorphic_map.
86
+
87
+ # The volume/issue pair is a scalar cluster: a reference that omits
88
+ # the issue must still match a hit that carries one ("P-1 Vol. 30"
89
+ # vs "P-1 Vol. 30 (2)"), so excluding the volume nils the issue
90
+ # with it — the same additive-alias shape the base applies to
91
+ # :year -> :date. A lone scalar :year needs no override here: the
92
+ # base nils a declared :year directly (the additive :year -> :date
93
+ # alias nils whichever name the flavor declares).
94
+ def exclude(*args)
95
+ args << :issue if args.include?(:volume)
96
+ super
97
+ end
72
98
  end
73
99
 
74
100
  end
@@ -9,6 +9,8 @@ module Pubid
9
9
  # "IHO P-1/21 1.0.0"
10
10
  # "IHO S-100 Part 4a 1.0.0"
11
11
  # "IHO S-65 Ap. A 1.0.0"
12
+ # "IHO P-5 2024" (year-identified series)
13
+ # "IHO P-1 Vol. 30 (2)" (volume/issue-identified series)
12
14
  #
13
15
  # The renderer is registered as the `:human` format in the IHO format
14
16
  # registry and invoked via `render(format: :human)`.
@@ -22,7 +24,13 @@ module Pubid
22
24
  rendered << " Part #{id.part}" if id.part
23
25
  rendered << " Annex #{id.annex}" if id.annex
24
26
  rendered << " Suppl #{id.supplement}" if id.supplement
27
+ # The grammar's tail picks exactly one discriminator: a dotted
28
+ # version, a year, or a volume (with an optional issue). The
29
+ # issue prints parenthesized; "Issue N" is an input alias only.
25
30
  rendered << " #{id.version}" if id.version
31
+ rendered << " #{id.year}" if id.year
32
+ rendered << " Vol. #{id.volume}" if id.volume
33
+ rendered << " (#{id.issue})" if id.issue
26
34
  rendered
27
35
  end
28
36
  end
@@ -4,8 +4,16 @@ module Pubid
4
4
  module Iho
5
5
  # Generates URN strings for IHO identifiers.
6
6
  #
7
- # Format: urn:iho:{type-letter-lowercase}:{number}[:ap.{appendix}][:part.{part}][:annex.{annex}][:suppl.{supplement}][:{version}]
7
+ # Format: urn:iho:{type-letter-lowercase}:{number}[:ap.{appendix}][:part.{part}][:annex.{annex}][:suppl.{supplement}][:vol.{volume}[:issue.{issue}]][:{version}][:y.{year}]
8
8
  # Example: urn:iho:s:44:5.0.0 for "IHO S-44 5.0.0".
9
+ # Only the dotted version is a bare segment. A year is tagged
10
+ # ("urn:iho:p:5:y.2024" for "IHO P-5 2024"), and so is a volume with
11
+ # its optional issue ("urn:iho:p:1:vol.30:issue.2" for
12
+ # "IHO P-1 Vol. 30 (2)").
13
+ #
14
+ # The number is not escaped, so "IHO S-158:100" gives
15
+ # "urn:iho:s:158:100". A bare year would then make "IHO S-158:2024"
16
+ # and "IHO S-158 2024" share one URN; the y. tag keeps them apart.
9
17
  class UrnGenerator
10
18
  attr_reader :identifier
11
19
 
@@ -21,7 +29,12 @@ module Pubid
21
29
  parts << "part.#{identifier.part}" if identifier.part
22
30
  parts << "annex.#{identifier.annex}" if identifier.annex
23
31
  parts << "suppl.#{identifier.supplement}" if identifier.supplement
32
+ # The grammar's tail picks exactly one discriminator; a volume
33
+ # may carry an issue beside it.
34
+ parts << "vol.#{identifier.volume}" if identifier.volume
35
+ parts << "issue.#{identifier.issue}" if identifier.issue
24
36
  parts << identifier.version.to_s if identifier.version
37
+ parts << "y.#{identifier.year}" if identifier.year
25
38
  parts.join(":")
26
39
  end
27
40
  end
@@ -5,53 +5,66 @@ module Pubid
5
5
  # Parses IHO URNs back into identifiers.
6
6
  #
7
7
  # UrnGenerator emits:
8
- # urn:iho:{type-lower}:{number}[:{part-kind}.{part}]:{version}
8
+ # urn:iho:{type-lower}:{number}[:{tag}.{value}]*[:{version}][:y.{year}]
9
9
  #
10
10
  # type-lower is the lowercase series letter (s, p, m, b, c).
11
- # part-kind is the lowercase part label (part, ap, annex, suppl).
12
- # version is the dotted version string.
11
+ # tag is a lowercase label: part, ap, annex, suppl, vol, issue or y.
12
+ # version is the dotted a.b.c edition, the only untagged tail.
13
+ #
14
+ # The number is not escaped, so it can span segments
15
+ # ("IHO S-158:100" gives "urn:iho:s:158:100"). Every untagged
16
+ # segment that is not a version therefore belongs to the number.
13
17
  #
14
18
  # Examples:
15
19
  # - urn:iho:s:44:5.0.0 → IHO S-44 5.0.0
16
20
  # - urn:iho:s:100:part.4a:1.0.0 → IHO S-100 Part 4a 1.0.0
17
21
  # - urn:iho:s:65:ap.A:1.0.0 → IHO S-65 Ap. A 1.0.0
22
+ # - urn:iho:s:158:100:1.0.0 → IHO S-158:100 1.0.0
23
+ # - urn:iho:p:5:y.2024 → IHO P-5 2024
24
+ # - urn:iho:p:1:vol.30 → IHO P-1 Vol. 30
25
+ # - urn:iho:p:1:vol.30:issue.2 → IHO P-1 Vol. 30 (2)
18
26
  class UrnParser < Pubid::UrnParser::Base
19
- PART_LABELS = {
20
- "part" => "Part",
21
- "ap" => "Ap.",
22
- "annex" => "Annex",
23
- "suppl" => "Suppl",
27
+ # Tag => the text that the human-readable form prints for it.
28
+ TAG_TEXT = {
29
+ "part" => ->(v) { " Part #{v}" },
30
+ "ap" => ->(v) { " Ap. #{v}" },
31
+ "annex" => ->(v) { " Annex #{v}" },
32
+ "suppl" => ->(v) { " Suppl #{v}" },
33
+ "vol" => ->(v) { " Vol. #{v}" },
34
+ "issue" => ->(v) { " (#{v})" },
35
+ "y" => ->(v) { " #{v}" },
24
36
  }.freeze
25
37
 
26
- def parse_urn(urn)
27
- body = strip_namespace(urn)
28
- parts = split_parts(body)
29
-
30
- type_token = parts.fetch(0).upcase
31
- number = parts.fetch(1)
32
- idx = 2
33
- part_label = nil
34
- if parts[idx] && part_kind?(parts[idx])
35
- kind, value = parts[idx].split(".", 2)
36
- label = PART_LABELS.fetch(kind.downcase) { kind.capitalize }
37
- part_label = "#{label} #{value}"
38
- idx += 1
39
- end
40
- version = parts[idx]
38
+ VERSION_SEGMENT = /\A\d+\.\d+\.\d+\z/
41
39
 
42
- text = "IHO #{type_token}-#{number}"
43
- text += " #{part_label}" if part_label
44
- text += " #{version}" if version
45
- flavor_parse(text)
40
+ def parse_urn(urn)
41
+ type_token, first, *rest = split_parts(strip_namespace(urn))
42
+ number = [first, *rest.take_while { |s| untagged_number?(s) }]
43
+ tail = rest.drop(number.size - 1).map { |s| segment_text(s, urn) }
44
+ flavor_parse("IHO #{type_token.upcase}-#{number.join(':')}#{tail.join}")
46
45
  end
47
46
 
48
47
  private
49
48
 
50
- # A part-kind token starts with letters followed by a dot, e.g.,
51
- # "part.4a", "ap.A", "annex.A", "suppl.1". A version like "5.0.0"
52
- # starts with a digit.
53
- def part_kind?(token)
54
- token.match?(/^[a-z]+\./i)
49
+ def untagged_number?(segment)
50
+ !tagged?(segment) && !segment.match?(VERSION_SEGMENT)
51
+ end
52
+
53
+ def tagged?(segment)
54
+ segment.match?(/\A[a-z]+\./i)
55
+ end
56
+
57
+ def segment_text(segment, urn)
58
+ return " #{segment}" if segment.match?(VERSION_SEGMENT)
59
+
60
+ tag, value = segment.split(".", 2)
61
+ render = TAG_TEXT[tag.downcase] if tagged?(segment)
62
+ unless render
63
+ raise Pubid::Errors::UrnParseError,
64
+ "Unexpected segment #{segment.inspect} in #{urn.inspect}"
65
+ end
66
+
67
+ render.call(value)
55
68
  end
56
69
  end
57
70
  end
data/lib/pubid/iho.rb CHANGED
@@ -11,7 +11,6 @@ module Pubid
11
11
  autoload :Builder, "#{__dir__}/iho/builder"
12
12
  autoload :Identifier, "#{__dir__}/iho/identifiers/base"
13
13
  autoload :Identifiers, "#{__dir__}/iho/identifiers"
14
- autoload :Parser, "#{__dir__}/iho/parser"
15
14
  autoload :Renderer, "#{__dir__}/iho/renderer"
16
15
  autoload :UrnGenerator, "#{__dir__}/iho/urn_generator"
17
16
  autoload :UrnParser, "#{__dir__}/iho/urn_parser"
@@ -64,7 +64,7 @@ module Pubid
64
64
 
65
65
  # @raise [Pubid::Errors::ParseError] if the string is not a valid ISBN
66
66
  def self.build_identifier(identifier)
67
- parsed = Pubid::Parg::Backend.parse(:isbn, identifier)
67
+ parsed = Pubid::Parg::Model.bound(:isbn, identifier)
68
68
  Builder.build(parsed)
69
69
  rescue ArgumentError => e
70
70
  # The Builder validates length and check digit. Surface that as a parse
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "parslet"
4
-
5
3
  module Pubid
6
4
  module Isbn
7
- # Parslet grammar for ISBN identifiers per ISO 2108.
5
+ # Grammar for ISBN identifiers per ISO 2108.
8
6
  #
9
7
  # Accepts:
10
8
  # [ISBN[ |:]] {10 or 13 digits with optional hyphens}
@@ -12,34 +10,7 @@ module Pubid
12
10
  # ISBN-10 final digit may be "X" (check digit value 10). Hyphens are
13
11
  # optional and may appear in any group position; they are preserved
14
12
  # for round-trip rendering.
15
- class Parser < ::Pubid::Parser::Grammar
16
- rule(:space) { str(" ") }
17
- rule(:colon) { str(":") }
18
- rule(:hyphen) { str("-") }
19
- rule(:digit) { match("[0-9]") }
20
- rule(:x_digit) { digit | str("X") }
21
-
22
- rule(:isbn_prefix) do
23
- (str("ISBN") >> (space | (space.maybe >> colon >> space.maybe))).maybe
24
- end
25
-
26
- rule(:digit_group) { digit.repeat(1) }
27
- rule(:x_group) { x_digit.repeat(1) }
28
-
29
- # Loose "ISBN-ish" token: digits or final X, separated by hyphens.
30
- rule(:isbn_body) do
31
- (digit_group >> (hyphen >> digit_group).repeat >>
32
- (hyphen >> x_digit).maybe).as(:body) |
33
- (digit.repeat(9, 12) >> x_digit.maybe).as(:body)
34
- end
35
-
36
- rule(:identifier) { isbn_prefix >> isbn_body }
37
-
38
- rule(:root) { identifier }
39
-
40
- def self.parse(input)
41
- new.parse(input)
42
- end
13
+ class Parser
43
14
  end
44
15
  end
45
16
  end
@@ -45,3 +45,69 @@
45
45
  needs a lookbehind, because `tc_number.value` and `edition.number.value` are
46
46
  different attributes — the first pass stripped `edition.number.value` too,
47
47
  and two corrigendum examples caught it.
48
+
49
+ - **Typed model bindings (Phase D): the builder consumes the grammar's bound
50
+ map, not a parse tree.** `Identifier.parse` and `Normalizer` parse through
51
+ `Pubid::Parg::Model.bound(:iso, …)`; `UrnParser#build_identifier` constructs
52
+ the same bound-map dialect. The grammar is edition 2 with every
53
+ builder-consumed capture bound (`grammars/iso.parg`, `bindings identifier`).
54
+ The conversion preserved byte fidelity: `to_s` and `to_hash` are identical
55
+ for all 7,859 fixture inputs replayed against a `main` baseline, and the
56
+ wasm engine agrees with Ruby on every tree and bound map (the
57
+ `corpus-parity` gate). **What to know before editing either side:**
58
+
59
+ - **Supplement layers capture into position-scoped prefixes** — `supp_*`
60
+ (first supplement), `supp2_*`/`supp3_*` (chain layers), `isor_supp_*`
61
+ (ISO/R supplement), `dsupp_*` (directive supplement), `bs1_/bs2_/bs3_`
62
+ (bundle items), `idf_*/idf_supp_*` (the joint's IDF side, whose rules are
63
+ inlined from the imported idf grammar so the names never collide with the
64
+ ISO ones). Bindings.collect_captures collects captures globally by name
65
+ and delivers the FIRST occurrence, so shared names across layers would
66
+ collapse a layer's number/date onto the base's. `Builder#build` peels the
67
+ layers outermost-first, re-keying each prefix onto the builder-input
68
+ names and nesting the rest as the layer's `:base`.
69
+ - **The base document's own edition captures as `base_edition`** wherever a
70
+ supplement layer's third_part can carry its own edition
71
+ (`supplement_base_no_third`, the joint base). The builder re-keys it onto
72
+ `:edition` only at the innermost base — moving it inside a peel step
73
+ mis-assigns the edition to the wrapper on multi-layer chains (the
74
+ `ISO/IEC Guide 98-3 ED1/Suppl 1:2008/Cor 1:2009` spec caught that).
75
+ - **No array bindings.** The wasm engine's array-group harvest pads
76
+ non-firing reps with empty hashes (parsanol-ruby#399), so Ruby and wasm
77
+ bound maps disagree on any input whose parse backtracks past the array
78
+ branch. Two consequences: copublishers capture as ONE scalar span
79
+ (`"/IEC/IEEE"`, at-least-one item, absent when none) that the builder
80
+ splits, and bundle items capture into `bs1_/bs2_/bs3_` prefixes the
81
+ builder assembles into `supplements`. Verified to hold unchanged at the
82
+ 1.3.121 pin (the capture-flatten release): the re-baked artifact's
83
+ grammar/entries/bindings/tests/tables are byte-identical to the 1.3.114
84
+ bake. A newer engine's `(raw)` wrapper interiors would replace both
85
+ shapes — re-visit when such a wrapper ships.
86
+ - **The artifact exposes `["identifier", "idf.identifier"]`** — the
87
+ `use idf` import survives the conversion so the joint composition's
88
+ entry surface stays available (the fleet gate asserts the entry list).
89
+ The joint's IDF side itself binds as `idf_*` scalars; the pilot's
90
+ `publisher -> publisher_name (string) preprocess: publisher_names`
91
+ binding is also kept (the T1 materialization asserts the full
92
+ organization name), and the Ruby builder's `cast` returns nil for
93
+ `:publisher_name` — the Ruby identifier has no such attribute. The
94
+ Ruby artifact load resolves `use idf` against `data/parg/idf.parg`,
95
+ which sits beside the vendored artifact.
96
+ - **Captures must inline their bodies to raw terms.** A capture wrapping a
97
+ bare rule ref exposes the ref's child nodes and cannot bind as a string
98
+ (`cannot bind a Array capture`), and date/language bodies are inlined at
99
+ all capture sites for that reason. Multi-line rules must be brace bodies
100
+ (line-mode rejects newlines inside groups).
101
+ - **The URN parser emits the same dialect**: its supplement loop writes
102
+ `supp_`/`supp2_`/`supp3_`-prefixed flat keys (the prefixes the builder
103
+ peels), not `{base:, **supp}` nesting; the builder peels `supp_year` and
104
+ `supp_supplement_version` the same way by prefix. Its raw-stage fallback
105
+ (`:stage`), `:document_type` and `:supplement_version` keys are
106
+ builder-input names, not grammar captures, and pass through `cast`
107
+ unchanged.
108
+ - **Grammar probes**: `ruby -e 'gem "parsanol", "1.3.121"; …'` against the
109
+ artifact (`parse_and_bind`) shows the bound map; `Model.bound(:iso, …)`
110
+ shows the builder's actual input. The artifact and its vendored twin
111
+ (`data/parg/iso.parg`) must move together — the twin is the embedded
112
+ source's provenance copy.
113
+