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
@@ -146,7 +146,7 @@ module Pubid
146
146
  related =
147
147
  begin
148
148
  Identifier.parse(m[1])
149
- rescue Parslet::ParseFailed
149
+ rescue Pubid::Errors::ParseError
150
150
  Identifier.new(parenthetical_content: m[1])
151
151
  end
152
152
  relationships = [Components::Relationship.new(
@@ -283,21 +283,90 @@ module Pubid
283
283
  # Parslet can return array of hashes - merge them
284
284
  parsed_hash = parsed.is_a?(Array) ? merge_parsed_array(parsed) : parsed
285
285
 
286
+ # The grammar binds the bare top-level draft clause as
287
+ # :draft_version_scalar (string) — the only cross-engine-stable
288
+ # scalar — and the composite draft rule as :draft_version_composite
289
+ # (raw). Surface the scalar under the top-level key the rest of the
290
+ # builder has always read, and the composite under the interior key
291
+ # the draft-element walks read.
292
+ if parsed_hash[:draft_version].nil? && parsed_hash[:draft_version_scalar]
293
+ parsed_hash[:draft_version] = parsed_hash.delete(:draft_version_scalar)
294
+ end
295
+ draft_elems = parsed_hash[:draft].is_a?(Array) ? parsed_hash[:draft] : [parsed_hash[:draft]]
296
+ draft_elems.each do |e|
297
+ next unless e.is_a?(Hash) && e[:draft_version].nil? && e[:draft_version_composite]
298
+
299
+ e[:draft_version] = e.delete(:draft_version_composite)
300
+ end
301
+ # The subpart run ("7.4-3-2" carries two) rides the raw
302
+ # part_subpart_year wrapper — the subparts[] array harvest emitted
303
+ # empty hashes for non-firing rep iterations on wasm
304
+ # (parsanol-ruby#399), so the run no longer binds top-level.
305
+ # Hoist the wrapper's part/year and deep-collect its interior
306
+ # subparts so every consumer — and every raw subtree built through
307
+ # here (the CSA dual's ieee_portion, the multi-numbered primary,
308
+ # the supplement bases) — sees the flat keys it has always read.
309
+ parsed_hash = unwrap_part_subpart_year(parsed_hash)
310
+
286
311
  # Grammar-captured dash dates (relaton's "/D-3-2017-07" and
287
312
  # "/R-x-2011-04" tails) land on the identifier's year/month exactly
288
313
  # like the number's own date clause. The revision-suffix form sits at
289
314
  # the top level; the draft tail nests under :draft — either as its
290
315
  # own element or wrapped in a draft_version hash.
316
+ # A revision element INSIDE the draft array is the draft rule's own
317
+ # split artifact (the version's trailing ".1" segment) — the spine
318
+ # captured the same value, and leaving it there makes the REV-date
319
+ # embed glue the draft date into the designator. A rev_inline
320
+ # capture never lands inside the draft array, so this only ever
321
+ # drops the artifact.
322
+ if parsed_hash[:draft].is_a?(Array) && parsed_hash[:revision]
323
+ artifact = parsed_hash[:draft].any? do |e|
324
+ e.is_a?(Hash) && e.key?(:revision) && e[:revision].to_s == parsed_hash[:revision].to_s
325
+ end
326
+ parsed_hash.delete(:revision) if artifact
327
+ end
328
+ # The edition-2 tree materializes a multi-segment draft version
329
+ # ("D2.1.1") as per-token nodes carrying no :draft_version key. The
330
+ # v1 tree degraded such a clause to a whole-clause LEAF string when
331
+ # no draft date follows (the designator survives through the
332
+ # Draft.parse inverse), and to a date-only hash when it does (the
333
+ # version is dropped, no draft built). Normalize to the v1 shape
334
+ # here — before the stage derivation and draft handling read it —
335
+ # so every consumer sees what v1 saw.
336
+ if parsed_hash[:draft].is_a?(Array)
337
+ draft_elems = parsed_hash[:draft].select { |e| e.is_a?(Hash) }
338
+ if !draft_elems.empty? && draft_elems.none? { |e| e.key?(:draft_version) }
339
+ has_date = draft_elems.any? { |e| e.key?(:draft_date_month) || e.key?(:draft_date_year) }
340
+ if has_date
341
+ date_elems = draft_elems.select do |e|
342
+ e.key?(:draft_date_month) || e.key?(:draft_date_year) || e.key?(:draft_date_day)
343
+ end
344
+ parsed_hash[:draft] = date_elems.inject({}, :merge)
345
+ else
346
+ parsed_hash[:draft] = draft_elems.flat_map { |e| e.values }.join
347
+ end
348
+ end
349
+ end
291
350
  drafts = parsed_hash[:draft].is_a?(Array) ? parsed_hash[:draft] : [parsed_hash[:draft]].compact
292
351
  drafts.each do |d|
293
352
  next unless d.is_a?(Hash) && d[:draft_version].is_a?(Hash)
294
353
  dv = d[:draft_version]
295
- d[:dash_year] = dv[:dash_year] if dv[:dash_year]
296
- d[:dash_month] = dv[:dash_month] if dv[:dash_month]
354
+ # The edition-2 grammar renames the nested draft-version dash
355
+ # captures (draft_version_dash_year/month) to keep them out of the
356
+ # flat bound map; the v1 tree spelling stays as the fallback.
357
+ d[:dash_year] = (dv[:draft_version_dash_year] || dv[:dash_year]) if
358
+ dv[:draft_version_dash_year] || dv[:dash_year]
359
+ d[:dash_month] = (dv[:draft_version_dash_month] || dv[:dash_month]) if
360
+ dv[:draft_version_dash_month] || dv[:dash_month]
297
361
  d.delete(:draft_version)
298
362
  end
299
363
  dash = parsed_hash[:dash_year] ? parsed_hash : nil
300
- dash ||= drafts.find { |d| d.is_a?(Hash) && d[:dash_year] }
364
+ # The draft_date captures rename with the draft_date_ prefix on the
365
+ # edition-2 grammar (nested-only, kept off the identity keys); v1
366
+ # spelling stays as the fallback.
367
+ dash ||= drafts.find do |d|
368
+ d.is_a?(Hash) && (d[:draft_date_dash_year] || d[:dash_year])
369
+ end
301
370
  if dash.nil? && parsed_hash[:draft]
302
371
  # A numeric month trailing a draft is the draft's own date: it
303
372
  # embeds in the designator ("D09, 04 2011" -> "D09-2011-04")
@@ -323,25 +392,32 @@ module Pubid
323
392
  if dash
324
393
  joined = drafts.filter_map { |d| d.is_a?(Hash) ? d[:draft_version] : nil }
325
394
  .map { |v| v.is_a?(Hash) ? v[:value] : v.to_s }.join
395
+ # dash is either the top-level spine (dash_year/dash_month) or the
396
+ # draft's own hash (draft_date_dash_year/month on the edition-2
397
+ # grammar, dash_year/month on v1) — read both spellings.
398
+ dash_y = dash[:dash_year] || dash[:draft_date_dash_year]
399
+ dash_m = dash[:dash_month] || dash[:draft_date_dash_month]
326
400
  bare_joint_draft = parsed_hash[:joint_publishers] &&
327
401
  parsed_hash[:part].nil? &&
328
402
  parsed_hash[:iso_stage].nil? &&
329
403
  drafts.any? { |d| d.is_a?(Hash) && d[:draft_version] }
330
404
  dash_led = joined.start_with?("-")
331
405
  if bare_joint_draft && dash_led
332
- parsed_hash[:part] = { value: dash[:dash_year] }
333
- elsif dash[:dash_month] &&
406
+ parsed_hash[:part] = { value: dash_y }
407
+ elsif dash_m &&
334
408
  (host = drafts.find { |d| d.is_a?(Hash) && d[:draft_version] })
335
409
  # The draft's own numeric date embeds in the designator
336
410
  # ("D2-2015-04") — the "D2, 04 2015" comma spelling
337
411
  # normalizes onto it (pubid ruling 2026-10-13).
338
412
  host[:draft_version] =
339
- [joined, dash[:dash_year], dash[:dash_month]].compact.join("-")
413
+ [joined, dash_y, dash_m].compact.join("-")
340
414
  dash.delete(:dash_year)
341
415
  dash.delete(:dash_month)
342
- elsif (dash[:dash_month] || dash_led) && parsed_hash[:year].nil?
343
- parsed_hash[:year] = dash[:dash_year]
344
- parsed_hash[:month] = dash[:dash_month]
416
+ dash.delete(:draft_date_dash_year)
417
+ dash.delete(:draft_date_dash_month)
418
+ elsif (dash_m || dash_led) && parsed_hash[:year].nil?
419
+ parsed_hash[:year] = dash_y
420
+ parsed_hash[:month] = dash_m
345
421
  else
346
422
  # Year-only tail embeds in the draft face ("D9-2006"), the model
347
423
  # the relaton-pinned renders use for corrupted update_codes
@@ -353,15 +429,17 @@ module Pubid
353
429
  # ("D9-2006", and with its month "D2-2015-04") — the
354
430
  # "D2, 04 2015" comma spelling normalizes onto it.
355
431
  host[:draft_version] =
356
- [joined, dash[:dash_year], dash[:dash_month]].compact.join("-")
432
+ [joined, dash_y, dash_m].compact.join("-")
357
433
  dash.delete(:dash_year)
358
434
  dash.delete(:dash_month)
435
+ dash.delete(:draft_date_dash_year)
436
+ dash.delete(:draft_date_dash_month)
359
437
  # the hyphenated "/D-2-2007" spelling's year the spine
360
438
  # captured at top level is the draft's own — vacate it
361
- parsed_hash.delete(:year) if parsed_hash[:year] == dash[:dash_year]
439
+ parsed_hash.delete(:year) if parsed_hash[:year] == dash_y
362
440
  else
363
- parsed_hash[:year] = dash[:dash_year]
364
- parsed_hash[:month] = dash[:dash_month]
441
+ parsed_hash[:year] = dash_y
442
+ parsed_hash[:month] = dash_m
365
443
  end
366
444
  end
367
445
  end
@@ -456,8 +534,11 @@ module Pubid
456
534
  # "IEEE P15026.2/D=FDIS-201008" (pubid#203 ruling). The
457
535
  # stage and date ride the designator; the ISO face is not
458
536
  # elected.
459
- year = extract_value(fd[:year])
460
- month = extract_value(fd[:month])
537
+ # fdraft's month/year rename with the fdraft_ prefix on the
538
+ # edition-2 grammar (nested-only); v1 spelling stays as the
539
+ # fallback.
540
+ year = extract_value(fd[:fdraft_year] || fd[:year])
541
+ month = extract_value(fd[:fdraft_month] || fd[:month])
461
542
  date = ""
462
543
  if year
463
544
  date = "-#{year}"
@@ -500,6 +581,11 @@ module Pubid
500
581
  def reconstruct_base_string(base_data)
501
582
  base_parts = []
502
583
 
584
+ # The part/subpart/year captures sit one level down, inside the
585
+ # raw part_subpart_year wrapper (parsanol-ruby#399) — hoist them
586
+ # before the flat reads below.
587
+ base_data = unwrap_part_subpart_year(base_data)
588
+
503
589
  # Extract publisher
504
590
  if base_data[:publishers]
505
591
  pub_data = base_data[:publishers]
@@ -536,11 +622,14 @@ module Pubid
536
622
  number_str += part_val if part_val
537
623
  end
538
624
 
539
- # Add subpart if present
625
+ # Add subpart if present — the bound run arrives as subparts[]
626
+ # harvest hashes ({subpart: …}); v1's flat spelling stays as the
627
+ # fallback.
540
628
  if base_data[:subpart]
541
629
  subparts = base_data[:subpart]
542
630
  subparts = [subparts] unless subparts.is_a?(Array)
543
631
  subparts.each do |sp|
632
+ sp = sp[:subpart] if sp.is_a?(Hash) && sp.key?(:subpart)
544
633
  subpart_val = extract_value(sp)
545
634
  number_str += subpart_val if subpart_val
546
635
  end
@@ -681,7 +770,7 @@ module Pubid
681
770
  def build_interpretation_supplement(parsed_hash)
682
771
  # Reconstruct base identifier string from parsed components (same logic as corrigendum)
683
772
  base_parts = []
684
- base_data = parsed_hash[:base]
773
+ base_data = unwrap_part_subpart_year(parsed_hash[:base])
685
774
 
686
775
  # Extract publisher
687
776
  if base_data[:publishers]
@@ -714,11 +803,14 @@ module Pubid
714
803
  number_str += part_val if part_val
715
804
  end
716
805
 
717
- # Add subpart if present
806
+ # Add subpart if present — the bound run arrives as subparts[]
807
+ # harvest hashes ({subpart: …}); v1's flat spelling stays as the
808
+ # fallback.
718
809
  if base_data[:subpart]
719
810
  subparts = base_data[:subpart]
720
811
  subparts = [subparts] unless subparts.is_a?(Array)
721
812
  subparts.each do |sp|
813
+ sp = sp[:subpart] if sp.is_a?(Hash) && sp.key?(:subpart)
722
814
  subpart_val = extract_value(sp)
723
815
  number_str += subpart_val if subpart_val
724
816
  end
@@ -754,7 +846,7 @@ module Pubid
754
846
  def build_conformance_supplement(parsed_hash)
755
847
  # Reconstruct base identifier string from parsed components (same logic as corrigendum)
756
848
  base_parts = []
757
- base_data = parsed_hash[:base]
849
+ base_data = unwrap_part_subpart_year(parsed_hash[:base])
758
850
 
759
851
  # Extract publisher
760
852
  if base_data[:publishers]
@@ -787,11 +879,14 @@ module Pubid
787
879
  number_str += part_val if part_val
788
880
  end
789
881
 
790
- # Add subpart if present
882
+ # Add subpart if present — the bound run arrives as subparts[]
883
+ # harvest hashes ({subpart: …}); v1's flat spelling stays as the
884
+ # fallback.
791
885
  if base_data[:subpart]
792
886
  subparts = base_data[:subpart]
793
887
  subparts = [subparts] unless subparts.is_a?(Array)
794
888
  subparts.each do |sp|
889
+ sp = sp[:subpart] if sp.is_a?(Hash) && sp.key?(:subpart)
795
890
  subpart_val = extract_value(sp)
796
891
  number_str += subpart_val if subpart_val
797
892
  end
@@ -1320,9 +1415,12 @@ module Pubid
1320
1415
  # Extract SI type (SI or PSI)
1321
1416
  si_type = extract_value(parsed[:si_type])
1322
1417
 
1323
- # Extract publishers (should be "IEEE/ASTM")
1324
- if parsed[:publishers]
1325
- publishers_str = extract_value(parsed[:publishers])
1418
+ # Extract publishers (should be "IEEE/ASTM") — the SI rule's bare
1419
+ # publishers clause binds as publishers_scalar (string); the
1420
+ # composite joint rows deliver :publishers (raw).
1421
+ pub_src = parsed[:publishers] || parsed[:publishers_scalar]
1422
+ if pub_src
1423
+ publishers_str = extract_value(pub_src)
1326
1424
  # Split by slash to get individual publishers
1327
1425
  pubs = publishers_str.split("/")
1328
1426
  attributes[:publisher] = pubs.first
@@ -1334,6 +1432,10 @@ module Pubid
1334
1432
 
1335
1433
  # For PSI (draft), create proper Draft component object
1336
1434
  if si_type == "PSI"
1435
+ # The bare top-level draft clause binds as :draft_version_scalar
1436
+ # (string) — surface it under the key this builder reads.
1437
+ parsed[:draft_version] = parsed.delete(:draft_version_scalar) if
1438
+ parsed[:draft_version].nil? && parsed[:draft_version_scalar]
1337
1439
  if parsed[:draft_version]
1338
1440
  draft_version = extract_value(parsed[:draft_version])
1339
1441
  # Create Draft component (Lutaml::Model object)
@@ -1904,6 +2006,42 @@ module Pubid
1904
2006
  str_value.length.positive? ? str_value : nil
1905
2007
  end
1906
2008
 
2009
+ # Deep-first collection of every :subpart node under a raw
2010
+ # part_subpart_year wrapper (Hash or Array, any nesting).
2011
+ def collect_subparts(node, out = [])
2012
+ case node
2013
+ when Hash
2014
+ node.each do |key, value|
2015
+ if key.to_sym == :subpart
2016
+ out << value
2017
+ else
2018
+ collect_subparts(value, out)
2019
+ end
2020
+ end
2021
+ when Array then node.each { |item| collect_subparts(item, out) }
2022
+ end
2023
+ out
2024
+ end
2025
+
2026
+ # The part/subpart/year captures ride the raw part_subpart_year
2027
+ # wrapper inside raw subtrees (parsanol-ruby#399: the subparts[]
2028
+ # array harvest is not cross-engine stable, so the run does not
2029
+ # bind top-level). Hoist the wrapper's part/year onto a copy of the
2030
+ # subtree and surface the interior subpart run under :subpart, so
2031
+ # the flat reads downstream see the shapes they always read.
2032
+ def unwrap_part_subpart_year(node)
2033
+ return node unless node.is_a?(Hash) && (run = node[:part_subpart_year])
2034
+
2035
+ nodes = run.is_a?(Array) ? run : [run]
2036
+ flat = nodes.select { |item| item.is_a?(Hash) }.inject({}, :merge)
2037
+ node = node.merge(flat.slice(:part, :year))
2038
+ unless node[:subpart]
2039
+ collected = collect_subparts(run)
2040
+ node = node.merge(subpart: collected) unless collected.empty?
2041
+ end
2042
+ node
2043
+ end
2044
+
1907
2045
  # Extract optional attribute if present
1908
2046
  def extract_optional(parsed, attributes, key)
1909
2047
  value = extract_value(parsed[key])
@@ -2004,15 +2142,20 @@ module Pubid
2004
2142
  iso_stage ||= extract_value(draft_data[:draft_iso_stage]) if draft_data[:draft_iso_stage]
2005
2143
  iso_iteration = extract_value(draft_data[:draft_iso_iteration]) if draft_data[:draft_iso_iteration]
2006
2144
 
2007
- # Extract date information from draft data
2008
- month_slice = draft_data[:month]
2145
+ # Extract date information from draft data. The edition-2 grammar
2146
+ # renames draft_date's nested month/year/day captures with the
2147
+ # draft_date_ prefix (kept off the identity keys); the v1
2148
+ # spellings stay as the fallback.
2149
+ month_slice = draft_data[:draft_date_month] || draft_data[:month]
2009
2150
  month = extract_value(month_slice) if month_slice
2010
- year = extract_value(draft_data[:year]) if draft_data[:year]
2151
+ year = extract_value(draft_data[:draft_date_year] || draft_data[:year]) if
2152
+ draft_data[:draft_date_year] || draft_data[:year]
2011
2153
  # The apostrophe-year catalogue convention ("P1484.20.1/D7,
2012
2154
  # May 07"): the 2-digit year expands with the <30 pivot so the
2013
2155
  # hash carries an unambiguous year.
2014
2156
  year = "#{year.to_i < 30 ? 20 : 19}#{year}" if year.to_s.match?(/\A\d{2}\z/)
2015
- day = extract_value(draft_data[:day]) if draft_data[:day]
2157
+ day = extract_value(draft_data[:draft_date_day] || draft_data[:day]) if
2158
+ draft_data[:draft_date_day] || draft_data[:day]
2016
2159
 
2017
2160
  # A REV-carrying draft embeds its print date into the designator
2018
2161
  # with the month canonicalized numeric (pubid ruling 2026-10-14):
@@ -2103,7 +2246,11 @@ module Pubid
2103
2246
  def handle_reaffirmed(parsed, attributes)
2104
2247
  if parsed[:reaffirmed].is_a?(Hash)
2105
2248
  reaf_data = parsed[:reaffirmed]
2106
- attributes[:reaffirmed] = extract_value(reaf_data[:year])
2249
+ # The edition-2 grammar renames the nested year capture
2250
+ # (reaffirmed_year) so the reaffirmation year cannot leak onto
2251
+ # the identity year key; v1 spelling stays as the fallback.
2252
+ attributes[:reaffirmed] =
2253
+ extract_value(reaf_data[:reaffirmed_year] || reaf_data[:year])
2107
2254
  elsif parsed[:reaffirmed]
2108
2255
  attributes[:reaffirmed] = extract_value(parsed[:reaffirmed])
2109
2256
  end
@@ -2278,7 +2425,7 @@ module Pubid
2278
2425
  id_strings.map do |id_str|
2279
2426
  # Use Identifier.parse for recursive parsing
2280
2427
  Identifier.parse(id_str)
2281
- rescue Parslet::ParseFailed
2428
+ rescue Pubid::Errors::ParseError
2282
2429
  # If parsing fails, create minimal identifier with just the string
2283
2430
  # This maintains graceful degradation
2284
2431
  Identifier.new(parenthetical_content: id_str)
@@ -23,13 +23,21 @@ module Pubid
23
23
  attribute :parts, :string, collection: true # Array of part strings
24
24
  attribute :original_separator, :string # Original separator (. or -) from parsing
25
25
 
26
+ # The separator BEFORE each part, when the source mixed them
27
+ # ("7.4-3-2" — dot after the number, dashes before the subparts).
28
+ # Runtime-only (a plain accessor, not a lutaml attribute): the
29
+ # serialized columns keep the single original_separator, and only
30
+ # a fresh parse knows the per-part spelling.
31
+ attr_accessor :original_separators
32
+
26
33
  def initialize(prefix: nil, number: nil, parts: nil,
27
- original_separator: nil)
34
+ original_separator: nil, original_separators: nil)
28
35
  super()
29
36
  self.prefix = prefix
30
37
  self.number = number
31
38
  self.parts = parts || []
32
39
  self.original_separator = original_separator
40
+ self.original_separators = original_separators
33
41
  end
34
42
 
35
43
  # Parse code string into components
@@ -55,15 +63,45 @@ original_separator: nil)
55
63
  components = remainder.split(/[.-]/)
56
64
  number = components.shift
57
65
 
66
+ # The separators AS PRINTED, one per part boundary — a code may
67
+ # mix them ("7.4-3-2": dot after the number, dashes before the
68
+ # subparts), and to_s re-joins with these instead of the uniform
69
+ # original_separator when they were captured.
70
+ separators = remainder.scan(/[.-]/)
71
+ per_part = separators.length == components.length ? separators : nil
72
+
58
73
  new(
59
74
  prefix: prefix,
60
75
  number: number,
61
76
  parts: components.empty? ? nil : components,
62
77
  original_separator: separator,
78
+ original_separators: per_part,
63
79
  )
64
80
  end
65
81
 
82
+ # The printed face: keeps the separator each part was printed
83
+ # with ("7.4-3-2"), per Pubid::SubsetMatch. Spellings that differ
84
+ # only in part separators denote the same document, so faces that
85
+ # must be spelling-stable — the URN and the joint IEEE/ISO faces —
86
+ # render through #canonical_to_s instead.
66
87
  def to_s
88
+ render_code(per_part: true)
89
+ end
90
+
91
+ # The canonical face: one uniform separator across every part
92
+ # boundary (the first distinct one the source printed, the same
93
+ # face this class rendered before per-part separators were kept).
94
+ def canonical_to_s
95
+ render_code(per_part: false)
96
+ end
97
+
98
+ def render(context: nil)
99
+ to_s
100
+ end
101
+
102
+ private
103
+
104
+ def render_code(per_part:)
67
105
  result = ""
68
106
  result += prefix if prefix
69
107
  result += number if number # Guard against nil number (e.g., "ANSI X")
@@ -95,17 +133,18 @@ original_separator: nil)
95
133
  separator = "."
96
134
  end
97
135
 
98
- parts.each do |part|
99
- result += separator + part
136
+ parts.each_with_index do |part, idx|
137
+ # A fresh parse of mixed-separator codes ("7.4-3-2") carries
138
+ # the separator each part was printed with; the canonical
139
+ # face (and an unknown per-part spelling) falls back to the
140
+ # uniform original separator (the serialized shape).
141
+ sep = (per_part && original_separators&.at(idx)) || separator
142
+ result += sep + part
100
143
  end
101
144
  end
102
145
 
103
146
  result
104
147
  end
105
-
106
- def render(context: nil)
107
- to_s
108
- end
109
148
  end
110
149
  end
111
150
  end
@@ -312,7 +312,7 @@ module Pubid
312
312
  else
313
313
  parse_single(result.input)
314
314
  end
315
- rescue Parslet::ParseFailed
315
+ rescue Pubid::Errors::ParseError
316
316
  parse_single(input)
317
317
  end
318
318
 
@@ -403,7 +403,10 @@ module Pubid
403
403
  return build_dual(result.parts) if result.dispatch == :dual_semicolon
404
404
  end
405
405
  parser_input = Parser.normalize_input(normalized)
406
- parsed = Pubid::Parg::Backend.parse(:ieee, parser_input)
406
+ # The edition-2 artifact's bindings block produces the builder-ready
407
+ # hash at the engine (typed scalars + raw wrappers); the tree
408
+ # backend's normalization it replaces read the edition-1 shapes.
409
+ parsed = Pubid::Parg::Model.bound(:ieee, parser_input)
407
410
  builder = Builder.new(Identifier)
408
411
  # Pass the original input string to builder for context
409
412
  builder.original_input = input
@@ -164,7 +164,10 @@ module Pubid
164
164
  # P11073.10418", #216); a plain ISO/IEC number keeps the
165
165
  # printed separator ("IEC/IEEE FDIS 60079-30-2", "ISO/IEC
166
166
  # 8802-3").
167
- code_str = code.to_s
167
+ # Canonical (uniform-separator) code: a joint document parses
168
+ # from both the dotted IEEE face and the dashed IEC face, and
169
+ # both spellings must render — and URN — identically.
170
+ code_str = code.canonical_to_s
168
171
  code_str = code_str.tr("-", ".") if code_str.start_with?("P")
169
172
  code_str += mark unless code_str.empty?
170
173
  parts << code_str if code_str && !code_str.empty?
@@ -213,8 +216,9 @@ module Pubid
213
216
  parts << draft_status if draft_status
214
217
 
215
218
  # Build code part — the P-state prints as spelled (P = project
216
- # draft; no P = standard). Never added or stripped.
217
- code_str = code.to_s
219
+ # draft; no P = standard). Never added or stripped. Canonical
220
+ # code for the same both-faces reason as the ISO face above.
221
+ code_str = code.canonical_to_s
218
222
 
219
223
  # Mark after the number, before the draft and the year
220
224
  code_str += mark unless code_str.empty?
@@ -39,7 +39,8 @@ module Pubid
39
39
 
40
40
  # Parse IRE identifier string
41
41
  def self.parse(input)
42
- parsed = Parser.new.parse(input)
42
+ parsed = Pubid::Parg::Model.bound(:ire, input,
43
+ entry: "ire_identifier")
43
44
  builder = Builder.new
44
45
  builder.build(parsed)
45
46
  end
@@ -5,7 +5,6 @@ module Pubid
5
5
  module Ire
6
6
  autoload :Builder, "#{__dir__}/ire/builder"
7
7
  autoload :Identifier, "#{__dir__}/ire/identifier"
8
- autoload :Parser, "#{__dir__}/ire/parser"
9
8
  end
10
9
  end
11
10
  end
@@ -94,7 +94,7 @@ module Pubid
94
94
  return Identifiers::Nesc::Preprint if parsed_hash[:preprint]
95
95
 
96
96
  # Draft identifiers
97
- return Identifiers::Nesc::Draft if parsed_hash[:draft]
97
+ return Identifiers::Nesc::Draft if parsed_hash[:draft] || parsed_hash[:nesc_draft_marker]
98
98
 
99
99
  # Variant-based identifiers
100
100
  if parsed_hash[:variant]
@@ -4,7 +4,6 @@ module Pubid
4
4
  module Ieee
5
5
  module Nesc
6
6
  autoload :Builder, "#{__dir__}/nesc/builder"
7
- autoload :Parser, "#{__dir__}/nesc/parser"
8
7
  end
9
8
  end
10
9
  end