keiyaku 0.1.0

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.
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime"
4
+
5
+ module Keiyaku
6
+ # Raised while translating something the generator cannot translate
7
+ # faithfully. Caught per operation, where it becomes a refusal; raised while
8
+ # building a component, it poisons that component and every operation that
9
+ # reaches it.
10
+ class Impossible < StandardError; end
11
+
12
+ # Every Ruby name the generator invents is decided here, so that a collision
13
+ # is something one table can see rather than something each site discovers
14
+ # separately — and so that a name Ruby will not accept is a refusal at
15
+ # generation time rather than a SyntaxError at somebody's first require.
16
+ module Names
17
+ module_function
18
+
19
+ IDENTIFIER = /\A[a-z_][a-zA-Z0-9_]*\z/
20
+
21
+ # KEYWORDS is Keiyaku's rather than this table's, because the runtime
22
+ # needs the same list: a parameter may be named for one, and the generated
23
+ # method body then has to read it back out of its binding.
24
+
25
+ # Names the generated client needs for itself. `def class` is legal Ruby
26
+ # and overrides the method every lookup in the runtime goes through, which
27
+ # is a client that recurses until the stack ends rather than one that
28
+ # fails to load.
29
+ CLIENT_METHODS = (Keiyaku::Client.instance_methods(false) +
30
+ Keiyaku::Client.private_instance_methods(false) + %i[initialize]).map(&:to_s).freeze
31
+
32
+ # A model's own contract: `with` and `to_h` are how a value type is used,
33
+ # `deconstruct_keys` is how it pattern matches, `to_json_hash` is how it
34
+ # becomes a request body, and `class` is how anything finds out what it
35
+ # is. A property taking one of those names leaves the model unable to do
36
+ # its job — unlike, say, one called `hash`, which plenty of real documents
37
+ # have and which costs only the model's use as a Hash key.
38
+ #
39
+ # The properties a document allows but does not name are read through the
40
+ # same `[]` that reads a declared one, so a model open to them spends no
41
+ # name here that a closed one does not — a reader called `extra` would
42
+ # have cost this table a word that real documents use as a property.
43
+ #
44
+ # `members` is not among them. The runtime asks the class for its members
45
+ # and never the instance, so a property of that name shadows a method
46
+ # nothing calls — and GitHub's AppPermissions has one.
47
+ MODEL_METHODS = %w[class with to_h deconstruct deconstruct_keys to_json to_json_hash].freeze
48
+
49
+ # The constants the generated files actually spend, and no more. `Client`
50
+ # is the class itself; `String` and the rest are what every other model's
51
+ # fields are declared as, so a schema of that name would quietly change
52
+ # what they cast to. A schema called `Range` or `File` shadows nothing
53
+ # here — the generated code never mentions either — and refusing it would
54
+ # be this table inventing a problem.
55
+ CONSTANTS = %w[Client Keiyaku String Integer Float Time Date].freeze
56
+
57
+ # `problem-details` and `problem_details` both want ProblemDetails, which
58
+ # is a collision the caller has to hear about rather than a name to
59
+ # invent a suffix for.
60
+ def constant(name)
61
+ const = name.to_s.split(/[^a-zA-Z0-9]+/).reject(&:empty?)
62
+ .map { |part| Keiyaku.camelize(part).sub(/\A./, &:upcase) }.join
63
+ raise Impossible, "#{name.inspect} cannot be a Ruby constant" unless const.match?(/\A[A-Z][a-zA-Z0-9]*\z/)
64
+ raise Impossible, "#{name.inspect} would be #{const}, which is spoken for" if CONSTANTS.include?(const)
65
+
66
+ const
67
+ end
68
+
69
+ # The module the generated files declare. It is the caller's word rather
70
+ # than the document's, so what this catches is a `--module` that would
71
+ # become three files Ruby cannot read — reported against the flag that
72
+ # asked for it rather than as the generator blaming itself in the load
73
+ # check. One constant and no more: `module Acme::Api` needs an Acme that
74
+ # nothing here defines, and is a NameError at the first require.
75
+ def namespace(name)
76
+ unless name.to_s.match?(/\A[A-Z][a-zA-Z0-9]*\z/)
77
+ raise Impossible, "#{name.inspect} cannot be the module the generated files declare; " \
78
+ "it has to be one Ruby constant, like Petstore"
79
+ end
80
+
81
+ name.to_s
82
+ end
83
+
84
+ # Not called `method`, which would shadow the one every object has — the
85
+ # kind of collision the rest of this table exists to refuse.
86
+ def operation(name)
87
+ # The separators have to become underscores before snake sees them: it
88
+ # drops anything that is not a word character, which would run the
89
+ # segments of a path together.
90
+ ruby = Keiyaku.snake(name.to_s.gsub(/[^a-zA-Z0-9]+/, "_")).squeeze("_").delete_prefix("_").delete_suffix("_")
91
+ raise Impossible, "#{name.inspect} cannot be a Ruby method name" unless ruby.match?(IDENTIFIER)
92
+ raise Impossible, "#{name.inspect} is a Ruby keyword" if KEYWORDS.include?(ruby)
93
+ raise Impossible, "#{name.inspect} is a method the client needs" if CLIENT_METHODS.include?(ruby)
94
+
95
+ ruby
96
+ end
97
+
98
+ # A parameter becomes an argument of the generated method, which is a
99
+ # local: it may shadow a method without harm. A keyword it may be too,
100
+ # but only where the argument is one — `def find(until: nil)` is a method
101
+ # Ruby will define and `def find(until)` is a file it will not read. So a
102
+ # query or header parameter keeps the document's name and the runtime
103
+ # reads it out of the binding, and a path parameter, which is positional
104
+ # because a URL's segments are ordered, is refused.
105
+ def parameter(name, positional: false)
106
+ ruby = Keiyaku.snake(name)
107
+ raise Impossible, "parameter #{name.inspect} cannot be a Ruby argument name" unless ruby.match?(IDENTIFIER)
108
+ if positional && KEYWORDS.include?(ruby)
109
+ raise Impossible, "path parameter #{name.inspect} is a Ruby keyword, which a positional argument cannot be"
110
+ end
111
+
112
+ ruby
113
+ end
114
+
115
+ # A field is a model's member, which is a method reached through a dot and
116
+ # a Hash key written as a label. Both take a keyword: `{ end: String }`
117
+ # and `range.end` are ordinary Ruby, and a date range is not an unusual
118
+ # shape for a document to have. Only the names below are actually spent.
119
+ #
120
+ # A name Ruby will not take as an identifier keeps the one the document
121
+ # gave it — GitHub counts its reactions in properties called `+1` and
122
+ # `-1`. A member may be called that: it casts, round-trips, copies
123
+ # with `with` and pattern matches as `in { "+1": n }`. The one thing it
124
+ # cannot do is be reached through a dot, so `model["+1"]` is how it is
125
+ # read, and the RBS types that rather than an attr_reader. Renaming it to
126
+ # something dot-shaped would mean inventing a name the document never
127
+ # used, which is the guess this generator exists not to make.
128
+ #
129
+ # The test is the first character rather than the result, because snake
130
+ # drops what it cannot use: `+1` survives it as `1`, which is refused, but
131
+ # `-1` survives as `_1`, which is a perfectly good identifier for a field
132
+ # that has nothing to do with negative one. Both belong to the same
133
+ # schema, and a model reading `rollup["+1"]` beside `rollup._1` would be
134
+ # the generator having translated one of them and mangled the other.
135
+ def field(name)
136
+ ruby = Keiyaku.snake(name)
137
+ return name.to_s if !name.match?(/\A[a-zA-Z_]/) || !ruby.match?(IDENTIFIER)
138
+ raise Impossible, "property #{name.inspect} is a method the model needs" if MODEL_METHODS.include?(ruby)
139
+
140
+ ruby
141
+ end
142
+
143
+ # Whether a field can be written as a bare label, in Ruby and in RBS both.
144
+ def bare?(field) = field.match?(IDENTIFIER)
145
+ end
146
+ end