pubid 2.0.8 → 2.0.10

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +2 -1
  3. data/data/parg/sae.json +1 -1
  4. data/lib/pubid/adobe/pubid_urn_schema.rb +14 -0
  5. data/lib/pubid/adobe.rb +1 -0
  6. data/lib/pubid/api/pubid_urn_schema.rb +2 -0
  7. data/lib/pubid/calconnect/pubid_urn_schema.rb +20 -0
  8. data/lib/pubid/calconnect.rb +1 -0
  9. data/lib/pubid/ccsds/pubid_urn_schema.rb +22 -0
  10. data/lib/pubid/ccsds.rb +1 -0
  11. data/lib/pubid/cen_cenelec/CLAUDE.md +17 -0
  12. data/lib/pubid/cen_cenelec/identifiers/cenelec_harmonization_document.rb +4 -1
  13. data/lib/pubid/cen_cenelec/identifiers.rb +1 -1
  14. data/lib/pubid/cen_cenelec/pubid_urn_schema.rb +35 -0
  15. data/lib/pubid/cen_cenelec.rb +1 -0
  16. data/lib/pubid/easc/pubid_urn_schema.rb +20 -0
  17. data/lib/pubid/easc.rb +1 -0
  18. data/lib/pubid/ecma/pubid_urn_schema.rb +21 -0
  19. data/lib/pubid/ecma.rb +1 -0
  20. data/lib/pubid/etsi/CLAUDE.md +9 -0
  21. data/lib/pubid/etsi/pubid_urn_schema.rb +36 -0
  22. data/lib/pubid/etsi.rb +1 -0
  23. data/lib/pubid/evs/pubid_urn_schema.rb +35 -0
  24. data/lib/pubid/evs.rb +1 -0
  25. data/lib/pubid/gost/CLAUDE.md +12 -0
  26. data/lib/pubid/gost/pubid_urn_schema.rb +34 -0
  27. data/lib/pubid/gost.rb +1 -0
  28. data/lib/pubid/iala/pubid_urn_schema.rb +30 -0
  29. data/lib/pubid/iala.rb +1 -0
  30. data/lib/pubid/iana/pubid_urn_schema.rb +17 -0
  31. data/lib/pubid/iana.rb +1 -0
  32. data/lib/pubid/identifier.rb +7 -1
  33. data/lib/pubid/iec/pubid_urn_schema.rb +11 -4
  34. data/lib/pubid/oasis/pubid_urn_schema.rb +21 -0
  35. data/lib/pubid/oasis.rb +1 -0
  36. data/lib/pubid/ogc/pubid_urn_schema.rb +18 -0
  37. data/lib/pubid/ogc.rb +1 -0
  38. data/lib/pubid/plateau/CLAUDE.md +13 -0
  39. data/lib/pubid/plateau/pubid_urn_schema.rb +25 -0
  40. data/lib/pubid/plateau.rb +1 -0
  41. data/lib/pubid/pubid_urn/decoder.rb +8 -2
  42. data/lib/pubid/pubid_urn/encoder.rb +14 -2
  43. data/lib/pubid/pubid_urn/schema.rb +18 -4
  44. data/lib/pubid/pubid_urn.rb +40 -0
  45. data/lib/pubid/sae/CLAUDE.md +78 -0
  46. data/lib/pubid/sae/builder.rb +89 -11
  47. data/lib/pubid/sae/components/date.rb +12 -3
  48. data/lib/pubid/sae/components/type.rb +54 -2
  49. data/lib/pubid/sae/identifiers/base.rb +54 -1
  50. data/lib/pubid/sae/renderer.rb +17 -20
  51. data/lib/pubid/sae/urn_generator.rb +6 -1
  52. data/lib/pubid/sae/urn_parser.rb +8 -3
  53. data/lib/pubid/version.rb +1 -1
  54. data/lib/pubid/w3c/pubid_urn_schema.rb +27 -0
  55. data/lib/pubid/w3c.rb +1 -0
  56. data/lib/pubid/xsf/pubid_urn_schema.rb +14 -0
  57. data/lib/pubid/xsf.rb +1 -0
  58. data/lib/pubid.rb +20 -0
  59. metadata +20 -2
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Oasis
5
+ # The pubid-scheme URN of oasis (issue #656; docs/URN-SCHEMES.adoc).
6
+ #
7
+ # Declared from the doc's section: the atom, facet and type tables
8
+ # below are the doc's design, conforming token-for-token.
9
+ PubidUrnSchema = Pubid::PubidUrn.define("oasis") do
10
+ atom "part", "part"
11
+ atom "version", "v"
12
+ atom "stage", "stage"
13
+ atom "label", "label"
14
+ atom "original", "original"
15
+
16
+
17
+ type "pubid:oasis:identifier", "id"
18
+ type "pubid:oasis:standard", "std"
19
+ end
20
+ end
21
+ end
data/lib/pubid/oasis.rb CHANGED
@@ -17,6 +17,7 @@ module Pubid
17
17
  autoload :Renderer, "#{__dir__}/oasis/renderer"
18
18
  autoload :UrnGenerator, "#{__dir__}/oasis/urn_generator"
19
19
  autoload :UrnParser, "#{__dir__}/oasis/urn_parser"
20
+ autoload :PubidUrnSchema, "#{__dir__}/oasis/pubid_urn_schema"
20
21
 
21
22
  # Parse an OASIS identifier string
22
23
  def self.parse(identifier)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Ogc
5
+ # The pubid-scheme URN of ogc (issue #656; docs/URN-SCHEMES.adoc).
6
+ #
7
+ # Declared from the doc's section: the atom, facet and type tables
8
+ # below are the doc's design, conforming token-for-token.
9
+ PubidUrnSchema = Pubid::PubidUrn.define("ogc") do
10
+ atom "year", "year"
11
+ atom "revision", "rev"
12
+
13
+
14
+ type "pubid:ogc:document", "document"
15
+ type "pubid:ogc:identifier", "id"
16
+ end
17
+ end
18
+ end
data/lib/pubid/ogc.rb CHANGED
@@ -20,6 +20,7 @@ module Pubid
20
20
  autoload :Renderer, "#{__dir__}/ogc/renderer"
21
21
  autoload :UrnGenerator, "#{__dir__}/ogc/urn_generator"
22
22
  autoload :UrnParser, "#{__dir__}/ogc/urn_parser"
23
+ autoload :PubidUrnSchema, "#{__dir__}/ogc/pubid_urn_schema"
23
24
 
24
25
  # Parse an OGC identifier string
25
26
  def self.parse(identifier)
@@ -0,0 +1,13 @@
1
+ # PLATEAU flavor notes
2
+
3
+ Read this before you change `lib/pubid/plateau/` or `spec/pubid/plateau/`. The
4
+ root `CLAUDE.md` keeps the cross-flavor contract that every flavor obeys.
5
+
6
+ ## pubid-scheme URN (issue #656)
7
+
8
+ `lib/pubid/plateau/pubid_urn_schema.rb`; rulings in `docs/URN-SCHEMES.adoc`
9
+ (`[[urn-plateau]]`). PLATEAU is the one flavor whose `number` stays an
10
+ Integer, so its core is `:int` (`core: [["number", :int]]`): `#00` is the core
11
+ `0`, and `00` in a URN is not canonical. The Annex type has no `number`; it
12
+ has no core, its document comes first along `base`, and its `letter` is an
13
+ atom. No input makes an Annex today, so the spec builds one by hand.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Plateau
5
+ # The pubid-scheme URN of PLATEAU (issue #656; docs/URN-SCHEMES.adoc).
6
+ #
7
+ # PLATEAU Handbook #00 第1.0版 -> urn:pubid:plateau:hb:0:ed-1.0
8
+ #
9
+ # PLATEAU keeps +number+ as an Integer (the one flavor the String retype
10
+ # left out), so it is an :int core. An Annex has no number of its own:
11
+ # its document comes first, along +base+, and its letter is an atom.
12
+ PubidUrnSchema = Pubid::PubidUrn.define("plateau") do
13
+ atom "publisher", "pub"
14
+ atom "edition", "ed"
15
+ atom "annex", "annex", kind: :int
16
+
17
+ number = [["number", :int]]
18
+ type "pubid:plateau:annex", "annex", core: [],
19
+ atoms: [%w[letter letter]]
20
+ type "pubid:plateau:handbook", "hb", core: number
21
+ type "pubid:plateau:identifier", "id", core: number
22
+ type "pubid:plateau:technical-report", "tr", core: number
23
+ end
24
+ end
25
+ end
data/lib/pubid/plateau.rb CHANGED
@@ -13,6 +13,7 @@ module Pubid
13
13
  autoload :Identifier, "#{__dir__}/plateau/identifiers/base"
14
14
  autoload :Identifiers, "#{__dir__}/plateau/identifiers"
15
15
  autoload :Parser, "#{__dir__}/plateau/parser"
16
+ autoload :PubidUrnSchema, "#{__dir__}/plateau/pubid_urn_schema"
16
17
  autoload :Renderer, "#{__dir__}/plateau/renderer"
17
18
  autoload :SupplementIdentifier, "#{__dir__}/plateau/supplement_identifier"
18
19
  autoload :UrnGenerator, "#{__dir__}/plateau/urn_generator"
@@ -62,18 +62,24 @@ module Pubid
62
62
  end
63
63
 
64
64
  def decode_core(tokens, index, node, schema)
65
- schema.core.each do |parts|
65
+ schema.core.each_with_index do |parts, i|
66
66
  break if index >= tokens.size
67
67
 
68
68
  token = tokens[index]
69
69
  break if PubidUrn.structural?(token)
70
70
 
71
- Paths.put(node, parts, Escape.decode(token)) unless token == NULL
71
+ unless token == NULL
72
+ Paths.put(node, parts, core_value(token, schema.core_kinds[i]))
73
+ end
72
74
  index += 1
73
75
  end
74
76
  index
75
77
  end
76
78
 
79
+ def core_value(token, kind)
80
+ kind == :int ? integer(token) : Escape.decode(token)
81
+ end
82
+
77
83
  def decode_atoms(tokens, index, node, schema)
78
84
  last = -1
79
85
  while index < tokens.size &&
@@ -69,11 +69,23 @@ module Pubid
69
69
  def core_segments(node, schema)
70
70
  values = schema.core.map { |parts| Paths.take(node, parts) }
71
71
  values.pop while !values.empty? && values.last.nil?
72
- values.map do |value|
73
- value.nil? ? NULL : core_value(value, schema)
72
+ values.each_with_index.map do |value, i|
73
+ next NULL if value.nil?
74
+ next core_integer(value, schema, i) if schema.core_kinds[i] == :int
75
+
76
+ core_value(value, schema)
74
77
  end
75
78
  end
76
79
 
80
+ # A decimal never has the shape of structure, so it needs no escape.
81
+ def core_integer(value, schema, index)
82
+ return value.to_s if value.is_a?(Integer)
83
+
84
+ raise Errors::InvalidInputError,
85
+ "#{schema.type} #{schema.core[index].join('.')}: " \
86
+ "#{value.inspect} is not int"
87
+ end
88
+
77
89
  # A core value that has the shape of structure (PubidUrn::STRUCTURE)
78
90
  # gets its first character escaped.
79
91
  def core_value(value, schema)
@@ -48,13 +48,15 @@ module Pubid
48
48
 
49
49
  # The URN shape of one concrete identifier type.
50
50
  class TypeSchema
51
- attr_reader :flavor, :type, :token, :core, :atoms, :facets
51
+ attr_reader :flavor, :type, :token, :core, :core_kinds, :atoms, :facets
52
52
 
53
+ # +core+ is a list of [path, kind] pairs; kind is :string or :int.
53
54
  def initialize(flavor:, type:, token:, core:, atoms:, facets:)
54
55
  @flavor = flavor
55
56
  @type = type
56
57
  @token = token
57
- @core = core.map { |path| path.split(".") }.freeze
58
+ @core = core.map { |path, _| path.split(".") }.freeze
59
+ @core_kinds = core.map { |_, kind| kind }.freeze
58
60
  @atoms = atoms.freeze
59
61
  @facets = facets.freeze
60
62
  @atom_index = atoms.each_with_index.to_h { |atom, i| [atom.prefix, i] }
@@ -86,6 +88,7 @@ module Pubid
86
88
  NAME = /\A[a-z][a-z0-9]*\z/
87
89
  KINDS = %i[string list bool bools int id ids].freeze
88
90
  FACET_KINDS = %i[string list bool bools].freeze
91
+ CORE_KINDS = %i[string int].freeze
89
92
  # "f" opens the facet group; "type" is the type-metadata URN
90
93
  # (Pubid::IdentifierMetadata, "urn:pubid:<flavor>:type:<key>").
91
94
  RESERVED_TOKENS = %w[f type].freeze
@@ -110,7 +113,9 @@ module Pubid
110
113
 
111
114
  # @param type [String] the +_type+ the class serializes
112
115
  # @param token [String] the short type token of the URN
113
- # @param core [Array<String>] the positional core paths
116
+ # @param core [Array<String, Array>] the positional core paths; an
117
+ # entry is a path (a String value) or [path, kind], kind :string or
118
+ # :int
114
119
  # @param atoms [Array<Array>] extra atoms: [path, prefix, kind]
115
120
  # @param facets [Array<Array>] extra facets: [path, name, kind]
116
121
  def type(type, token, core: %w[number], atoms: [], facets: [])
@@ -126,7 +131,8 @@ module Pubid
126
131
  check_unique!(all_facets.map(&:name), "facet name", type)
127
132
 
128
133
  schema = TypeSchema.new(flavor: flavor, type: type, token: token,
129
- core: core, atoms: all_atoms,
134
+ core: core.map { |spec| build_core(*spec) },
135
+ atoms: all_atoms,
130
136
  facets: all_facets)
131
137
  @types[type] = schema
132
138
  @tokens[token] = schema
@@ -142,6 +148,14 @@ module Pubid
142
148
 
143
149
  private
144
150
 
151
+ def build_core(path, kind = :string)
152
+ unless CORE_KINDS.include?(kind)
153
+ raise ArgumentError, "unknown core kind #{kind.inspect}"
154
+ end
155
+
156
+ [path, kind]
157
+ end
158
+
145
159
  def build_atom(path, prefix, kind = nil)
146
160
  check_name!(prefix, "atom prefix")
147
161
  Atom.new(path, prefix, check_kind!(path, kind, KINDS, "atom"))
@@ -106,6 +106,46 @@ module Pubid
106
106
  string.is_a?(String) && string.b[0, PREFIX.length].downcase == PREFIX
107
107
  end
108
108
 
109
+ # The slug form (relaton#441): the URN wearing HTTP-path clothes.
110
+ # Each ":" segment becomes a "/" segment, after the "urn:pubid:"
111
+ # prefix is cut — "urn:pubid:iso:is:9001:year-2015" is
112
+ # "iso/is/9001/year-2015". The contract's path-safety gate
113
+ # guarantees no segment carries a "/", so the transform is
114
+ # bijective: {from_slug} rebuilds the exact URN.
115
+ #
116
+ # @param source [Identifier, String] an identifier or its
117
+ # pubid-scheme URN
118
+ # @return [String] the slug, with no leading or trailing "/"
119
+ # @raise [Errors::InvalidInputError] when a String is not a
120
+ # canonical pubid-scheme URN
121
+ def to_slug(source)
122
+ urn = source.respond_to?(:to_pubid_urn) ? source.to_pubid_urn : source
123
+ parse(urn) unless source.respond_to?(:to_pubid_urn)
124
+ urn.delete_prefix(PREFIX).tr(":", "/")
125
+ end
126
+
127
+ # The slug parse side: "/"-joined segments rebuild the URN and
128
+ # parse through the canonical gate, so a slug of a landed flavor
129
+ # round-trips by +to_hash+ exactly like its URN.
130
+ #
131
+ # @param string [String] the slug
132
+ # @return [Identifier]
133
+ # @raise [Errors::InvalidInputError] when +string+ is not a String
134
+ # or is longer than MAX_LENGTH
135
+ # @raise [Errors::UrnParseError] for a malformed, unknown or
136
+ # non-canonical slug
137
+ def from_slug(string)
138
+ unless string.is_a?(String)
139
+ raise Errors::InvalidInputError, Pubid::INPUT_NOT_A_STRING_MESSAGE
140
+ end
141
+ raise Errors::InvalidInputError,
142
+ "slug exceeds #{MAX_LENGTH} characters" if string.length > MAX_LENGTH
143
+ raise Errors::UrnParseError, "slug has empty segments" if
144
+ string.split("/", -1).any?(&:empty?)
145
+
146
+ parse("#{PREFIX}#{string.tr('/', ':')}")
147
+ end
148
+
109
149
  # @return [FlavorSchema, nil]
110
150
  def flavor_schema(flavor)
111
151
  return @schemas[flavor] if @schemas.key?(flavor)
@@ -0,0 +1,78 @@
1
+ # SAE flavor notes
2
+
3
+ ## Source of the codes: SAE's Crossref deposits
4
+
5
+ relaton indexes every SAE standard from Crossref (prefix `10.4271`, type
6
+ `standard`). The DOI suffix is SAE's own edition code, lower-case and without
7
+ hyphens: `10.4271/j3016_202104` is `SAE J3016_202104`, `10.4271/amsqqa250_4b`
8
+ is `SAE AMS-QQ-A-250/4B`. relaton upper-cases the suffix and prefixes `SAE `,
9
+ so pubid must read the hyphen-free spelling as well as the printed one.
10
+
11
+ Measured on the full deposit list (2026-11-04, 47,692 DOIs): **46,726
12
+ parse**, and every parsed code round-trips through `to_s`, `from_hash` and
13
+ the URN, with **0 `to_hash` collisions**. The 966 that do not parse are:
14
+
15
+ - **791 CP\*** (`CPGM`, `CPFD`, `CPKW`, …): collected-paper products, not
16
+ standards. Out of scope by decision.
17
+ - **175 one-offs**, none more than 32 of a kind: `SUP1` supplements
18
+ (`AS4468SUP1A`, `ARP4737SUP1`), language tails (`AS9101F_PTBR`,
19
+ `IA9137_DEDE`), `J2534-2/10_5_00`, `_4Q2012`, type-less `SAE6857`,
20
+ `_MMYYYY` dates (`CS1939_042012`, `J2012DA_012013` — the builder's
21
+ month check refuses the J ones, which would otherwise read as year 120,
22
+ month 13), `ISO/SAE21434`, `IEEE/EIA12207`, and ~30 families of 1–8
23
+ documents whose printed form is unknown (`HEB`, `EQB`, `TAHB`, `AMSLP`,
24
+ `AMSHHG`, …). Add a family by adding it to `Components::Type::TYPES` and
25
+ re-baking the grammar (below).
26
+
27
+ ## Model
28
+
29
+ | attribute | example | `===` |
30
+ |---|---|---|
31
+ | `type.abbr` (printed form) | `AMS`, `AMS-QQ-A`, `GEIA-STD` | stated |
32
+ | `number` | `4121`, `0001`, `1939DA`, `174M`, `1939-71` | stated |
33
+ | `part` | `2` of `J2337/2`, `H1025` of `AMS5622/H1025` | strict |
34
+ | `revision` | `J` of `AMS4121J`, `6` of `USCAR2-6` | wildcard |
35
+ | `amendment` | `1` of `AS5259/7A_A1` | strict |
36
+ | `date` (`year`, `month`) | `J3016_202104` | month is a wildcard |
37
+
38
+ The grammar only segments the code; `builder.rb` decides what each segment
39
+ means, because the meaning depends on the family:
40
+
41
+ - **A one-letter tail is the revision** (`AMS4121J`, `AS22520/45A`), so every
42
+ revision of a document shares one `number` bucket and `SAE AMS4121 ===`
43
+ each of them. relaton then picks the highest revision.
44
+ - **Except the `M` of a J or JA report**, which marks the metric document
45
+ (`J174M`) and stays in `number`. AMS revisions do reach `M` (`AMS2400M`),
46
+ so the exception is per family (`Type::METRIC_SUFFIX`).
47
+ - **A longer tail is another document** (`J1939DA` digital annex,
48
+ `J2735SET`) and stays in `number`, in its own bucket.
49
+ - **A dash number is part of `number`** (`J1939-71`, `EIA649-2`; the DOI
50
+ writes some of them `_1`: `EIA649_1A` → `EIA-649-1A`). For USCAR the dash
51
+ is the revision (`USCAR2-6` is USCAR-2 Revision 6, `Type::DASH_REVISION`).
52
+ - **`part` and `amendment` are `subset_strict`**: `SAE J2337` does not match
53
+ `SAE J2337/2`, and `SAE AS5259/7A` does not match `SAE AS5259/7A_A1`.
54
+
55
+ Rendering follows SAE: no space after the type (`SAE J3016`, the old
56
+ `SAE J 3016` still parses), a hyphen after a family that kept a military or
57
+ industry series hyphen (`Type::TYPES` holds the separator), `_YYYYMM` when
58
+ the month is known and `:YYYY` otherwise. The amendment tail `_A1` has no
59
+ known printed form and keeps the DOI's.
60
+
61
+ ## The grammar is baked here from a hand-off source
62
+
63
+ `data/parg/sae.json` was compiled with the pinned parsanol from the source
64
+ recorded in hand-off `pubid__pubid-grammar__sae-crossref-coverage` (the same
65
+ text sits in the artifact's `source` field). Until pubid-grammar lands it,
66
+ `pubid-grammar/grammars/sae.parg` is the OLD grammar, so `parsanol parg
67
+ check` against it reports stale. To change the grammar, extract the source
68
+ (`jq -r .source data/parg/sae.json`), edit, and re-bake:
69
+
70
+ ```bash
71
+ bundle exec parsanol parg compile sae.parg -o data/parg/sae.json
72
+ bundle exec parsanol parg test data/parg/sae.json
73
+ ```
74
+
75
+ The `doc_type` alternatives are the `Type::TYPES` keys plus their
76
+ hyphen-free spellings, longest first (PEG commits to the first match, so
77
+ `AMS` comes after every `AMS…`). `spec/pubid/sae/type_table_spec.rb` fails
78
+ when the two lists drift or an alternative shadows a longer one.
@@ -2,31 +2,109 @@
2
2
 
3
3
  module Pubid
4
4
  module Sae
5
+ # Builds a Pubid::Sae::Identifier from the grammar's bound captures.
6
+ #
7
+ # The grammar only segments the code (data/parg/sae.json):
8
+ # +type number [dash] [tail] [/part [part_tail]] [_A amendment] [date]+.
9
+ # The meaning of each segment is decided here, because it depends on the
10
+ # family:
11
+ #
12
+ # * +type+ is stored in its printed form (+AMSQQA+ -> +AMS-QQ-A+).
13
+ # * A one-letter +tail+ or +part_tail+ is the revision (+AMS4121J+,
14
+ # +AS22520/45A+), except the +M+ of a J or JA report, which marks the
15
+ # metric document (+J174M+) and stays in the number.
16
+ # * A longer tail names another document (+J1939DA+, the digital annex;
17
+ # +J2735SET+) and stays in the number, so it keeps its own index bucket.
18
+ # * A +dash+ is a USCAR revision (+USCAR2-6+); in every other family it
19
+ # is part of the number (+J1939-71+, +EIA649-2+).
5
20
  class Builder < Pubid::Builder::Base
6
21
  def self.build(parsed_data)
7
22
  new.build(parsed_data)
8
23
  end
9
24
 
25
+ def build(data)
26
+ data = flatten_array(data) if data.is_a?(Array)
27
+ data = data.transform_keys(&:to_sym)
28
+ Identifier.new(**attributes_for(data))
29
+ end
30
+
10
31
  private
11
32
 
12
33
  def default_identifier_class
13
34
  Identifier
14
35
  end
15
36
 
16
- def cast(type, value)
17
- case type
18
- when :publisher
19
- "SAE"
20
- when :type
21
- Components::Type.new(abbr: value.to_s)
22
- when :number
23
- value.to_s
24
- when :year
25
- { date: Components::Date.new(year: value.to_i) }
37
+ def attributes_for(data)
38
+ abbr = Components::Type.canonical(data[:type])
39
+ attrs = { type: Components::Type.new(abbr: abbr),
40
+ number: data[:number].to_s }
41
+ apply_dash(attrs, abbr, data[:dash])
42
+ apply_tail(attrs, abbr, data[:tail], part: data[:part])
43
+ apply_part(attrs, data[:part], data[:part_tail])
44
+ attrs[:amendment] = data[:amendment].to_s if data[:amendment]
45
+ attrs[:date] = date_for(data) if data[:year]
46
+ attrs
47
+ end
48
+
49
+ def apply_dash(attrs, abbr, dash)
50
+ return unless dash
51
+
52
+ if Components::Type::DASH_REVISION.include?(abbr)
53
+ attrs[:revision] = dash.to_s
54
+ else
55
+ attrs[:number] += "-#{dash}"
56
+ end
57
+ end
58
+
59
+ # A one-letter tail before a part (AMS4121J/2) or after a USCAR dash
60
+ # revision (USCAR2-6A) has no place in the model: no SAE code has that
61
+ # shape, and storing it in `number` would split the index bucket and
62
+ # print a string that does not parse back. Such input is refused.
63
+ def apply_tail(attrs, abbr, tail, part:)
64
+ return unless tail
65
+
66
+ tail = tail.to_s
67
+ if !revision_letter?(abbr, tail)
68
+ attrs[:number] += tail
69
+ elsif part || attrs[:revision]
70
+ refuse("a revision letter #{tail} before a part or after a " \
71
+ "dash revision")
72
+ else
73
+ attrs[:revision] = tail
74
+ end
75
+ end
76
+
77
+ def apply_part(attrs, part, part_tail)
78
+ return unless part
79
+
80
+ attrs[:part] = part.to_s
81
+ return unless part_tail
82
+
83
+ if part_tail.to_s.length == 1
84
+ attrs[:revision] = part_tail.to_s
26
85
  else
27
- value
86
+ attrs[:part] += part_tail.to_s
28
87
  end
29
88
  end
89
+
90
+ def revision_letter?(abbr, tail)
91
+ return false unless tail.length == 1
92
+
93
+ !(tail == "M" && Components::Type::METRIC_SUFFIX.include?(abbr))
94
+ end
95
+
96
+ def date_for(data)
97
+ month = data[:month]&.to_i
98
+ refuse("month #{data[:month]}") if month && !(1..12).cover?(month)
99
+
100
+ Components::Date.new(year: data[:year].to_i, month: month)
101
+ end
102
+
103
+ def refuse(what)
104
+ raise Pubid::Errors::ParseError.new(
105
+ "SAE identifier with #{what} is not supported", nil, flavor: "sae"
106
+ )
107
+ end
30
108
  end
31
109
  end
32
110
  end
@@ -5,12 +5,18 @@ require "lutaml/model"
5
5
  module Pubid
6
6
  module Sae
7
7
  module Components
8
- # Date component for SAE standards
9
- # SAE uses year only (e.g., 2024, 2022)
8
+ # The edition date of an SAE document.
9
+ #
10
+ # SAE dates a J report by year and month: its edition code is
11
+ # +J3016_202104+, the April 2021 edition. A reference may state the
12
+ # year alone (+SAE J3016:2021+); the month is then nil, and nil is a
13
+ # wildcard under +===+, so the year-only reference matches every
14
+ # edition of that year.
10
15
  class Date < Lutaml::Model::Serializable
11
16
  include ::Pubid::SubsetMatch
12
17
 
13
18
  attribute :year, :integer
19
+ attribute :month, :integer
14
20
 
15
21
  def present?
16
22
  !year.nil?
@@ -20,8 +26,11 @@ module Pubid
20
26
  render
21
27
  end
22
28
 
29
+ # +202104+ with a month (SAE's edition code), +2021+ without.
23
30
  def render(context: nil)
24
- year.to_s
31
+ return year.to_s unless month
32
+
33
+ format("%<year>04d%<month>02d", year: year, month: month)
25
34
  end
26
35
  end
27
36
  end
@@ -5,13 +5,65 @@ require "lutaml/model"
5
5
  module Pubid
6
6
  module Sae
7
7
  module Components
8
- # Type component for SAE document types
9
- # AMS, AIR, ARP, AS, MA
8
+ # The SAE document family: the letters before the number.
9
+ #
10
+ # +abbr+ holds the printed form (+AMS-QQ-A+). SAE's DOI suffixes drop
11
+ # the hyphens (+10.4271/amsqqa250_4b+), so the parser reads both
12
+ # spellings and the builder stores the printed one (see
13
+ # +.canonical+).
10
14
  class Type < Lutaml::Model::Serializable
11
15
  include ::Pubid::SubsetMatch
12
16
 
17
+ # Printed form => the separator SAE prints between it and the number.
18
+ # A family transferred from a military or industry series keeps that
19
+ # series' hyphen (+AMS-QQ-A-250+, +GEIA-STD-0001+, +EIA-649+); the
20
+ # SAE-native families print the number glued (+AMS4121J+, +J3016+).
21
+ # The grammar's +doc_type+ alternatives list exactly these keys plus
22
+ # their hyphen-free spellings; +spec/pubid/sae/type_table_spec.rb+
23
+ # keeps the two in step.
24
+ TYPES = {
25
+ "AMS" => "", "AS" => "", "J" => "", "ARP" => "", "AIR" => "",
26
+ "MA" => "", "MAM" => "", "MAP" => "", "JA" => "", "MS" => "",
27
+ "ARD" => "", "TSB" => "", "CMB" => "", "SSB" => "",
28
+ "CRP" => "-", "USCAR" => "-",
29
+ "EIA" => "-", "EIA-IS" => "-", "EIA-STD" => "-",
30
+ "GEIA" => "-", "GEIA-STD" => "-", "GEIA-HB" => "-", "GEIA-OP" => "-",
31
+ "GEIA-TB" => "-",
32
+ "AMS-STD" => "-", "AMS-DTL" => "-", "AMS-WW-T" => "-",
33
+ "AMS-QQ-A" => "-", "AMS-QQ-C" => "-", "AMS-QQ-N" => "-",
34
+ "AMS-QQ-P" => "-", "AMS-QQ-S" => "-", "AMS-QQ-W" => "-",
35
+ "AMS-PRF" => "-",
36
+ "AMS-A" => "-", "AMS-B" => "-", "AMS-C" => "-", "AMS-D" => "-",
37
+ "AMS-F" => "-",
38
+ "AMS-G" => "-", "AMS-H" => "-", "AMS-I" => "-", "AMS-L" => "-",
39
+ "AMS-M" => "-", "AMS-P" => "-", "AMS-R" => "-", "AMS-S" => "-",
40
+ "AMS-T" => "-", "AMS-W" => "-"
41
+ }.freeze
42
+
43
+ # Hyphen-free spelling => printed form.
44
+ CANONICAL = TYPES.keys.to_h { |abbr| [abbr.delete("-"), abbr] }.freeze
45
+
46
+ # The families whose revision is a dash number (+USCAR2-6+ is
47
+ # USCAR-2 Revision 6), not a letter.
48
+ DASH_REVISION = %w[USCAR].freeze
49
+
50
+ # The families whose trailing +M+ marks the metric edition of a
51
+ # document (+J174M+), not a revision.
52
+ METRIC_SUFFIX = %w[J JA].freeze
53
+
13
54
  attribute :abbr, :string
14
55
 
56
+ # @param spelling [String] +AMSQQA+, +AMS-QQ-A+ or +ams-qq-a+
57
+ # @return [String, nil] the printed form, nil for an unknown family
58
+ def self.canonical(spelling)
59
+ CANONICAL[spelling.to_s.upcase.delete("-")]
60
+ end
61
+
62
+ # The separator printed between this family and its number.
63
+ def separator
64
+ TYPES.fetch(abbr.to_s, "")
65
+ end
66
+
15
67
  def to_s
16
68
  render
17
69
  end
@@ -4,7 +4,7 @@ module Pubid
4
4
  module Sae
5
5
  # Base SAE Identifier. Canonical name Pubid::Sae::Identifier. SAE has a single identifier class that IS the
6
6
  # document type, so this class is both the base and the only concrete type.
7
- # Handles all SAE document types (AMS, AIR, ARP, AS, MA).
7
+ # Handles every SAE document family (Components::Type::TYPES).
8
8
  class Identifier < ::Pubid::Identifier
9
9
  def self.parse(input)
10
10
  unless input.is_a?(String)
@@ -26,8 +26,19 @@ module Pubid
26
26
  # a bare alias of the shared component and never carried more than
27
27
  # `value`, so this is a representation change only.
28
28
  attribute :number, :string
29
+ # The revision letter (AMS4121J -> "J"), or USCAR's revision number
30
+ # (USCAR2-6 -> "6"). Kept out of `number`, so every revision of a
31
+ # document shares one index bucket and a reference without a revision
32
+ # matches them all under `===`.
33
+ attribute :revision, :string
34
+ # The amendment of a revision: AS5259/7A_A1 -> "1".
35
+ attribute :amendment, :string
29
36
  attribute :date, Sae::Components::Date
30
37
 
38
+ # A part (J2337/2) and an amendment (_A1) are other documents: a
39
+ # reference without one names the document that has none.
40
+ subset_strict :part, :amendment
41
+
31
42
  def self.type
32
43
  { key: :base, web: :standard, title: "Standard", short: "SAE" }
33
44
  end
@@ -44,6 +55,48 @@ module Pubid
44
55
  def mr_type
45
56
  type&.abbr&.to_s&.downcase
46
57
  end
58
+
59
+ # The code after the type and its separator, without the date:
60
+ # "4121J", "22520/45A", "5259/7A_A1", "2-6" (USCAR-2 Revision 6).
61
+ # The renderer prints it after the type; the URN carries it as one
62
+ # segment.
63
+ def designation
64
+ dash = dash_revision?
65
+ code = number.to_s
66
+ code += "-#{revision}" if revision && dash
67
+ code += "/#{part}" if part
68
+ code += revision.to_s if revision && !dash
69
+ code += "_A#{amendment}" if amendment
70
+ code
71
+ end
72
+
73
+ # A revision after a part (AS22520/45A) is the part document's
74
+ # revision, so dropping the part drops it too. Without this,
75
+ # `exclude(:part)` would turn it into revision A of the base document
76
+ # (AS22520A) and `matches?(other, ignore: [:part])` would pair the two.
77
+ def exclude(*args)
78
+ args += [:revision] if part && args.include?(:part)
79
+ super
80
+ end
81
+
82
+ # A numbered revision of a USCAR family (USCAR-2-6). A letter revision
83
+ # (USCAR3A) prints glued, as in every other family.
84
+ def dash_revision?
85
+ Sae::Components::Type::DASH_REVISION.include?(type&.abbr.to_s) &&
86
+ revision.to_s.match?(/\A\d+\z/)
87
+ end
88
+
89
+ # Each segment carries a letter, so a dash number (J1939-71 ->
90
+ # "1939-71") never collides with a part (J1939/71 -> "1939-p71").
91
+ def mr_number_with_part
92
+ return nil unless number
93
+
94
+ segments = [number]
95
+ segments << "p#{part}" if part
96
+ segments << "r#{revision}" if revision
97
+ segments << "a#{amendment}" if amendment
98
+ segments.join("-").downcase
99
+ end
47
100
  end
48
101
 
49
102
  end