protostar 0.1.0.beta1

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 (223) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +60 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +647 -0
  5. data/THIRD_PARTY_NOTICES.md +61 -0
  6. data/exe/protoc-gen-protostar +7 -0
  7. data/exe/protostar +7 -0
  8. data/lib/protostar/builder/generator.rb +71 -0
  9. data/lib/protostar/builder/sink.rb +35 -0
  10. data/lib/protostar/builder.rb +965 -0
  11. data/lib/protostar/cli.rb +267 -0
  12. data/lib/protostar/codec/decode_generator.rb +199 -0
  13. data/lib/protostar/codec/decoder.rb +296 -0
  14. data/lib/protostar/codec/encode_generator.rb +224 -0
  15. data/lib/protostar/codec/encoder.rb +121 -0
  16. data/lib/protostar/codec/sizer.rb +91 -0
  17. data/lib/protostar/codec.rb +15 -0
  18. data/lib/protostar/compiler/ast.rb +193 -0
  19. data/lib/protostar/compiler/emitters/descriptor_set.rb +57 -0
  20. data/lib/protostar/compiler/emitters/google_ruby.rb +103 -0
  21. data/lib/protostar/compiler/emitters/grpc.rb +175 -0
  22. data/lib/protostar/compiler/emitters/protostar_rbs.rb +252 -0
  23. data/lib/protostar/compiler/emitters/rbs.rb +394 -0
  24. data/lib/protostar/compiler/emitters/ruby.rb +503 -0
  25. data/lib/protostar/compiler/emitters.rb +268 -0
  26. data/lib/protostar/compiler/include/LICENSE +32 -0
  27. data/lib/protostar/compiler/include/google/protobuf/any.proto +106 -0
  28. data/lib/protostar/compiler/include/google/protobuf/api.proto +229 -0
  29. data/lib/protostar/compiler/include/google/protobuf/compiler/plugin.proto +180 -0
  30. data/lib/protostar/compiler/include/google/protobuf/cpp_features.proto +87 -0
  31. data/lib/protostar/compiler/include/google/protobuf/descriptor.proto +1532 -0
  32. data/lib/protostar/compiler/include/google/protobuf/duration.proto +115 -0
  33. data/lib/protostar/compiler/include/google/protobuf/empty.proto +51 -0
  34. data/lib/protostar/compiler/include/google/protobuf/field_mask.proto +243 -0
  35. data/lib/protostar/compiler/include/google/protobuf/java_features.proto +132 -0
  36. data/lib/protostar/compiler/include/google/protobuf/source_context.proto +48 -0
  37. data/lib/protostar/compiler/include/google/protobuf/struct.proto +111 -0
  38. data/lib/protostar/compiler/include/google/protobuf/timestamp.proto +145 -0
  39. data/lib/protostar/compiler/include/google/protobuf/type.proto +217 -0
  40. data/lib/protostar/compiler/include/google/protobuf/wrappers.proto +157 -0
  41. data/lib/protostar/compiler/lexer.rb +659 -0
  42. data/lib/protostar/compiler/linker.rb +894 -0
  43. data/lib/protostar/compiler/option_interpreter.rb +363 -0
  44. data/lib/protostar/compiler/parser.rb +1696 -0
  45. data/lib/protostar/compiler/retention.rb +104 -0
  46. data/lib/protostar/compiler/run.rb +184 -0
  47. data/lib/protostar/compiler/source_info.rb +161 -0
  48. data/lib/protostar/compiler/symbol_table.rb +223 -0
  49. data/lib/protostar/compiler/text_format.rb +340 -0
  50. data/lib/protostar/compiler/validator.rb +944 -0
  51. data/lib/protostar/compiler.rb +304 -0
  52. data/lib/protostar/diagnostics/codes.rb +1307 -0
  53. data/lib/protostar/diagnostics/formats.rb +64 -0
  54. data/lib/protostar/diagnostics/renderer.rb +240 -0
  55. data/lib/protostar/diagnostics/source.rb +153 -0
  56. data/lib/protostar/diagnostics.rb +117 -0
  57. data/lib/protostar/dsl.rb +367 -0
  58. data/lib/protostar/enum.rb +70 -0
  59. data/lib/protostar/errors.rb +23 -0
  60. data/lib/protostar/google/protobuf/any_pb.rb +90 -0
  61. data/lib/protostar/google/protobuf/api_pb.rb +199 -0
  62. data/lib/protostar/google/protobuf/compiler/plugin_pb.rb +168 -0
  63. data/lib/protostar/google/protobuf/descriptor_pb.rb +1239 -0
  64. data/lib/protostar/google/protobuf/duration_pb.rb +99 -0
  65. data/lib/protostar/google/protobuf/empty_pb.rb +36 -0
  66. data/lib/protostar/google/protobuf/field_mask_pb.rb +228 -0
  67. data/lib/protostar/google/protobuf/source_context_pb.rb +33 -0
  68. data/lib/protostar/google/protobuf/struct_pb.rb +91 -0
  69. data/lib/protostar/google/protobuf/timestamp_pb.rb +129 -0
  70. data/lib/protostar/google/protobuf/type_pb.rb +202 -0
  71. data/lib/protostar/google/protobuf/wrappers_pb.rb +125 -0
  72. data/lib/protostar/google_protobuf_shim.rb +24 -0
  73. data/lib/protostar/json/encoder.rb +340 -0
  74. data/lib/protostar/json/errors.rb +34 -0
  75. data/lib/protostar/json/parser.rb +437 -0
  76. data/lib/protostar/json/path.rb +39 -0
  77. data/lib/protostar/json.rb +95 -0
  78. data/lib/protostar/lint/command.rb +197 -0
  79. data/lib/protostar/lint/config.rb +98 -0
  80. data/lib/protostar/lint/context.rb +126 -0
  81. data/lib/protostar/lint/directives.rb +56 -0
  82. data/lib/protostar/lint/fixer.rb +165 -0
  83. data/lib/protostar/lint/naming.rb +54 -0
  84. data/lib/protostar/lint/rule.rb +120 -0
  85. data/lib/protostar/lint/rules/comment_enum.rb +31 -0
  86. data/lib/protostar/lint/rules/comment_enum_value.rb +32 -0
  87. data/lib/protostar/lint/rules/comment_field.rb +33 -0
  88. data/lib/protostar/lint/rules/comment_message.rb +31 -0
  89. data/lib/protostar/lint/rules/comment_rpc.rb +31 -0
  90. data/lib/protostar/lint/rules/comment_service.rb +31 -0
  91. data/lib/protostar/lint/rules/edition_recommended.rb +31 -0
  92. data/lib/protostar/lint/rules/enum_first_value_zero.rb +34 -0
  93. data/lib/protostar/lint/rules/enum_pascal_case.rb +25 -0
  94. data/lib/protostar/lint/rules/enum_value_prefix.rb +38 -0
  95. data/lib/protostar/lint/rules/enum_value_upper_snake_case.rb +28 -0
  96. data/lib/protostar/lint/rules/enum_zero_value_suffix.rb +41 -0
  97. data/lib/protostar/lint/rules/field_lower_snake_case.rb +34 -0
  98. data/lib/protostar/lint/rules/field_not_required.rb +39 -0
  99. data/lib/protostar/lint/rules/field_number_reuse_risk.rb +45 -0
  100. data/lib/protostar/lint/rules/field_numbers_hot_range.rb +44 -0
  101. data/lib/protostar/lint/rules/file_lower_snake_case.rb +27 -0
  102. data/lib/protostar/lint/rules/import_no_public.rb +29 -0
  103. data/lib/protostar/lint/rules/import_no_weak.rb +28 -0
  104. data/lib/protostar/lint/rules/import_used.rb +33 -0
  105. data/lib/protostar/lint/rules/message_pascal_case.rb +27 -0
  106. data/lib/protostar/lint/rules/no_deprecated_features.rb +50 -0
  107. data/lib/protostar/lint/rules/no_groups.rb +34 -0
  108. data/lib/protostar/lint/rules/oneof_lower_snake_case.rb +27 -0
  109. data/lib/protostar/lint/rules/package_defined.rb +28 -0
  110. data/lib/protostar/lint/rules/package_directory_match.rb +33 -0
  111. data/lib/protostar/lint/rules/package_lower_snake_case.rb +28 -0
  112. data/lib/protostar/lint/rules/package_same_directory.rb +39 -0
  113. data/lib/protostar/lint/rules/package_version_suffix.rb +31 -0
  114. data/lib/protostar/lint/rules/reserved_names_with_numbers.rb +44 -0
  115. data/lib/protostar/lint/rules/rpc_pascal_case.rb +25 -0
  116. data/lib/protostar/lint/rules/rpc_request_response_unique.rb +40 -0
  117. data/lib/protostar/lint/rules/rpc_request_standard_name.rb +32 -0
  118. data/lib/protostar/lint/rules/rpc_response_standard_name.rb +31 -0
  119. data/lib/protostar/lint/rules/service_pascal_case.rb +25 -0
  120. data/lib/protostar/lint/rules/service_suffix.rb +28 -0
  121. data/lib/protostar/lint/rules/syntax_specified.rb +35 -0
  122. data/lib/protostar/lint/runner.rb +135 -0
  123. data/lib/protostar/lint.rb +122 -0
  124. data/lib/protostar/message/accessors.rb +86 -0
  125. data/lib/protostar/message/coercion.rb +171 -0
  126. data/lib/protostar/message/field_info.rb +145 -0
  127. data/lib/protostar/message/reflection.rb +149 -0
  128. data/lib/protostar/message.rb +572 -0
  129. data/lib/protostar/plugin.rb +94 -0
  130. data/lib/protostar/schema/default_value.rb +113 -0
  131. data/lib/protostar/schema/descriptor.rb +41 -0
  132. data/lib/protostar/schema/draft/copy.rb +88 -0
  133. data/lib/protostar/schema/draft/resolution.rb +177 -0
  134. data/lib/protostar/schema/draft.rb +779 -0
  135. data/lib/protostar/schema/enum.rb +106 -0
  136. data/lib/protostar/schema/extension_range.rb +37 -0
  137. data/lib/protostar/schema/features.rb +153 -0
  138. data/lib/protostar/schema/field.rb +201 -0
  139. data/lib/protostar/schema/file.rb +217 -0
  140. data/lib/protostar/schema/freezer.rb +61 -0
  141. data/lib/protostar/schema/message.rb +104 -0
  142. data/lib/protostar/schema/oneof.rb +38 -0
  143. data/lib/protostar/schema/options.rb +367 -0
  144. data/lib/protostar/schema/pool.rb +179 -0
  145. data/lib/protostar/schema/proto.rb +576 -0
  146. data/lib/protostar/schema/resolver.rb +118 -0
  147. data/lib/protostar/schema/service.rb +89 -0
  148. data/lib/protostar/schema/source_info.rb +53 -0
  149. data/lib/protostar/schema.rb +65 -0
  150. data/lib/protostar/short_names/any.rb +7 -0
  151. data/lib/protostar/short_names/duration.rb +7 -0
  152. data/lib/protostar/short_names/empty.rb +7 -0
  153. data/lib/protostar/short_names/field_mask.rb +7 -0
  154. data/lib/protostar/short_names/struct.rb +9 -0
  155. data/lib/protostar/short_names/timestamp.rb +7 -0
  156. data/lib/protostar/short_names/wrappers.rb +15 -0
  157. data/lib/protostar/text/extensions.rb +114 -0
  158. data/lib/protostar/text/parser.rb +439 -0
  159. data/lib/protostar/text/printer.rb +304 -0
  160. data/lib/protostar/text/tokenizer.rb +116 -0
  161. data/lib/protostar/text.rb +67 -0
  162. data/lib/protostar/types.rb +152 -0
  163. data/lib/protostar/unknown_field.rb +58 -0
  164. data/lib/protostar/version.rb +5 -0
  165. data/lib/protostar/view/generator.rb +298 -0
  166. data/lib/protostar/view/map.rb +194 -0
  167. data/lib/protostar/view/repeated.rb +143 -0
  168. data/lib/protostar/view/scan.rb +183 -0
  169. data/lib/protostar/view.rb +283 -0
  170. data/lib/protostar/well_known/any.rb +52 -0
  171. data/lib/protostar/well_known/duration.rb +53 -0
  172. data/lib/protostar/well_known/list_value.rb +63 -0
  173. data/lib/protostar/well_known/struct.rb +55 -0
  174. data/lib/protostar/well_known/timestamp.rb +64 -0
  175. data/lib/protostar/well_known/value.rb +60 -0
  176. data/lib/protostar/well_known.rb +38 -0
  177. data/lib/protostar/wire/io_source.rb +40 -0
  178. data/lib/protostar/wire/reader.rb +254 -0
  179. data/lib/protostar/wire/writer.rb +201 -0
  180. data/lib/protostar/wire.rb +96 -0
  181. data/lib/protostar.rb +95 -0
  182. data/shims/google-protobuf/lib/google/protobuf/any_pb.rb +17 -0
  183. data/shims/google-protobuf/lib/google/protobuf/api_pb.rb +22 -0
  184. data/shims/google-protobuf/lib/google/protobuf/compiler/plugin_pb.rb +25 -0
  185. data/shims/google-protobuf/lib/google/protobuf/descriptor_pb.rb +72 -0
  186. data/shims/google-protobuf/lib/google/protobuf/descriptor_pool.rb +164 -0
  187. data/shims/google-protobuf/lib/google/protobuf/descriptors.rb +553 -0
  188. data/shims/google-protobuf/lib/google/protobuf/duration_pb.rb +17 -0
  189. data/shims/google-protobuf/lib/google/protobuf/empty_pb.rb +17 -0
  190. data/shims/google-protobuf/lib/google/protobuf/field_mask_pb.rb +17 -0
  191. data/shims/google-protobuf/lib/google/protobuf/internal/convert.rb +180 -0
  192. data/shims/google-protobuf/lib/google/protobuf/internal/extensions.rb +154 -0
  193. data/shims/google-protobuf/lib/google/protobuf/internal/json.rb +104 -0
  194. data/shims/google-protobuf/lib/google/protobuf/internal/well_known.rb +140 -0
  195. data/shims/google-protobuf/lib/google/protobuf/map.rb +216 -0
  196. data/shims/google-protobuf/lib/google/protobuf/message.rb +544 -0
  197. data/shims/google-protobuf/lib/google/protobuf/message_exts.rb +41 -0
  198. data/shims/google-protobuf/lib/google/protobuf/plugin_pb.rb +4 -0
  199. data/shims/google-protobuf/lib/google/protobuf/repeated_field.rb +366 -0
  200. data/shims/google-protobuf/lib/google/protobuf/source_context_pb.rb +17 -0
  201. data/shims/google-protobuf/lib/google/protobuf/struct_pb.rb +20 -0
  202. data/shims/google-protobuf/lib/google/protobuf/timestamp_pb.rb +17 -0
  203. data/shims/google-protobuf/lib/google/protobuf/type_pb.rb +27 -0
  204. data/shims/google-protobuf/lib/google/protobuf/well_known_types.rb +37 -0
  205. data/shims/google-protobuf/lib/google/protobuf/wrappers_pb.rb +25 -0
  206. data/shims/google-protobuf/lib/google/protobuf.rb +107 -0
  207. data/shims/google-protobuf/lib/google/protobuf_ffi.rb +5 -0
  208. data/shims/google-protobuf/lib/google/protobuf_native.rb +5 -0
  209. data/sig/protostar/google/protobuf/any_pb.rbs +77 -0
  210. data/sig/protostar/google/protobuf/api_pb.rbs +242 -0
  211. data/sig/protostar/google/protobuf/compiler/plugin_pb.rbs +264 -0
  212. data/sig/protostar/google/protobuf/descriptor_pb.rbs +2220 -0
  213. data/sig/protostar/google/protobuf/duration_pb.rbs +82 -0
  214. data/sig/protostar/google/protobuf/empty_pb.rbs +18 -0
  215. data/sig/protostar/google/protobuf/field_mask_pb.rbs +170 -0
  216. data/sig/protostar/google/protobuf/source_context_pb.rbs +24 -0
  217. data/sig/protostar/google/protobuf/struct_pb.rbs +135 -0
  218. data/sig/protostar/google/protobuf/timestamp_pb.rbs +102 -0
  219. data/sig/protostar/google/protobuf/type_pb.rbs +304 -0
  220. data/sig/protostar/google/protobuf/wrappers_pb.rbs +169 -0
  221. data/sig/protostar/lint.rbs +216 -0
  222. data/sig/protostar.rbs +326 -0
  223. metadata +366 -0
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # FIELD_NUMBER_REUSE_RISK: A message with gaps in its field numbers that are not reserved
7
+ class FieldNumberReuseRisk < Rule
8
+ REASON = "A deleted field's number that is not reserved will be reused, and old data will decode as the new field."
9
+ EXPLANATION = <<~TEXT
10
+ When a field is deleted, its number is still in every message written before. If a later field takes the
11
+ number, old data decodes into it as if it were the new field: wrong values, or parse errors when the
12
+ types differ. `reserved` makes protoc refuse the number (and the name) for good.
13
+
14
+ message User {
15
+ string name = 1;
16
+ // email was 2
17
+ string phone = 3;
18
+ }
19
+
20
+ Fix: `reserved 2; reserved "email";`. If the gap was never used, reserving it costs nothing either.
21
+ TEXT
22
+
23
+ def check
24
+ context.each(:message) do |node, message|
25
+ numbers = message.fields.map(&:number)
26
+ next if numbers.empty?
27
+ covered = (numbers.map { |n| n..n } + message.reserved_ranges + message.extension_ranges +
28
+ [19_000..19_999]).sort_by(&:first)
29
+ gaps = []
30
+ next_free = 1
31
+ covered.each do |r|
32
+ break if next_free >= numbers.max
33
+ gaps << (next_free..[r.first - 1, numbers.max - 1].min) if r.first > next_free
34
+ next_free = [next_free, r.last + 1].max
35
+ end
36
+ next if gaps.empty?
37
+ text = gaps.map { |r| (r.size == 1) ? r.first.to_s : "#{r.first} to #{r.last}" }.join(", ")
38
+ report(node, :name, "`#{message.name}` skips field #{(gaps.sum(&:size) == 1) ? "number" : "numbers"} #{text} without reserving them",
39
+ label: "gaps in the numbering", help: "if fields were deleted there, add `reserved #{text};` and reserve their names")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # FIELD_NUMBERS_HOT_RANGE: A message using field numbers above 15 while numbers 1 to 15 are free
7
+ class FieldNumbersHotRange < Rule
8
+ REASON = "Field numbers 1 to 15 take one byte on the wire and 16 to 2047 two, so the low ones are best kept for the most common fields."
9
+ EXPLANATION = <<~TEXT
10
+ A field's tag is its number and wire type in a varint: 1 to 15 fit in one byte, 16 to 2047 need two. In a
11
+ message with many fields, the frequently set ones should have the low numbers. This rule only notices
12
+ when 1 to 15 have free numbers or deprecated fields while higher numbers are in use; whether the high
13
+ fields are the frequent ones is a judgement it leaves to you.
14
+
15
+ message Event {
16
+ string id = 1;
17
+ string payload = 16;
18
+ }
19
+
20
+ Fix: give new frequent fields the free low numbers. Never renumber an existing field: that breaks the
21
+ wire format.
22
+ TEXT
23
+
24
+ def check
25
+ context.each(:message) do |node, message|
26
+ high = message.fields.select { |f| f.number > 15 }
27
+ next if high.empty?
28
+ used = message.fields.map(&:number)
29
+ covered = message.reserved_ranges + message.extension_ranges
30
+ free = (1..15).reject { |n| used.include?(n) || covered.any? { |r| r.cover?(n) } }
31
+ stale = message.fields.select { |f| f.number <= 15 && f.options.deprecated? }
32
+ next if free.empty? && stale.empty?
33
+ parts = []
34
+ parts << "#{numbers_text(free)} free" unless free.empty?
35
+ parts << "deprecated #{stale.map { |f| "`#{f.name}`" }.join(", ")}" unless stale.empty?
36
+ report(node, :name, "`#{message.name}` uses numbers above 15 with #{parts.join(" and ")} in the one-byte range",
37
+ label: "#{high.size} #{(high.size == 1) ? "field" : "fields"} above 15",
38
+ help: "give new, frequently set fields numbers from 1 to 15; do not renumber existing ones")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # FILE_LOWER_SNAKE_CASE: A file name that is not lower_snake_case
7
+ class FileLowerSnakeCase < Rule
8
+ REASON = "Generated file and module names follow the .proto's name; lower_snake_case works on every file system and in every language."
9
+ EXPLANATION = <<~TEXT
10
+ Code generators derive file, module and class names from the `.proto` file's name. Mixed case breaks on
11
+ case-insensitive file systems and reads oddly as a Python module or Go file.
12
+
13
+ acme/UserProfile.proto
14
+
15
+ Fix: rename the file to `acme/user_profile.proto` and update the imports.
16
+ TEXT
17
+
18
+ def check
19
+ base = ::File.basename(context.name, ".proto")
20
+ return if Naming.lower_snake?(base)
21
+ add("file name `#{::File.basename(context.name)}` is not lower_snake_case", [],
22
+ help: "rename the file `#{Naming.lower_snake(base)}.proto`")
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # IMPORT_NO_PUBLIC: An `import public`
7
+ class ImportNoPublic < Rule
8
+ REASON = "`import public` makes a file re-export another's types, which hides real dependencies and makes moving types hard."
9
+ EXPLANATION = <<~TEXT
10
+ `import public "b.proto";` in a.proto lets every file that imports a.proto use b.proto's types without
11
+ importing it. It exists for moving types between files; as a habit it hides who depends on what, and not
12
+ every language supports it.
13
+
14
+ import public "acme/common.proto";
15
+
16
+ Fix: import the file directly wherever its types are used.
17
+ TEXT
18
+
19
+ def check
20
+ context.ast.public_dependency.each do |index|
21
+ name = context.ast.dependency[index]
22
+ add("import of `#{name}` is public", [context.span_at([3, index], label: "re-exports `#{name}`")].compact,
23
+ help: "import `#{name}` directly in the files that use it")
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # IMPORT_NO_WEAK: An `import weak`
7
+ class ImportNoWeak < Rule
8
+ REASON = "Weak imports are an internal Google feature that most implementations do not support."
9
+ EXPLANATION = <<~TEXT
10
+ `import weak` lets a file use types from a file that might not be linked in. Only some C++ and Java
11
+ builds honour it, and edition 2024 removed it in favour of `import option`.
12
+
13
+ import weak "acme/debug.proto";
14
+
15
+ Fix: use a plain import (or `import option` for files that only provide options, from edition 2024).
16
+ TEXT
17
+
18
+ def check
19
+ context.ast.weak_dependency.each do |index|
20
+ name = context.ast.dependency[index]
21
+ add("import of `#{name}` is weak", [context.span_at([3, index], label: "weak")].compact,
22
+ help: "use a plain `import`")
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # IMPORT_USED: An import nothing in the file uses
7
+ class ImportUsed < Rule
8
+ REASON = "An unused import is a dependency in the build graph that does nothing but slow it down and mislead readers."
9
+ EXPLANATION = <<~TEXT
10
+ protoc warns about these too; with this rule on, the warning is reported once, under this rule. An
11
+ import is used when a type, extension or option from it (or from what it imports publicly) is named in
12
+ the file.
13
+
14
+ import "google/protobuf/timestamp.proto";
15
+ message A { string id = 1; }
16
+
17
+ Fix: remove the import.
18
+ TEXT
19
+ CLAIMS = ["V0703"].freeze
20
+
21
+ def check
22
+ context.warnings.each do |warning|
23
+ next unless warning.code == "V0703"
24
+ name = warning.message[/\AImport (.*) is unused\.\z/, 1]
25
+ spans = warning.spans.map { |s| s.primary? ? s.with_label("unused") : s }
26
+ spans = [Diagnostics::Span.new(warning.line, warning.column, label: "unused")] if spans.empty? && warning.line
27
+ add("import `#{name}` is unused", spans, help: "remove the import")
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # MESSAGE_PASCAL_CASE: A message name that is not PascalCase
7
+ class MessagePascalCase < Rule
8
+ REASON = "Messages become classes; PascalCase is what every style guide and generator expects."
9
+ EXPLANATION = <<~TEXT
10
+ Message names turn into class names in generated code.
11
+
12
+ message user_profile {}
13
+
14
+ Fix: `message UserProfile {}`, and update the fields and files that refer to it.
15
+ TEXT
16
+
17
+ def check
18
+ context.each(:message) do |node, message|
19
+ next if Naming.pascal?(message.name)
20
+ report(node, :name, "message `#{message.name}` is not PascalCase", label: "not PascalCase",
21
+ help: "name it `#{Naming.pascal(message.name)}` and update what refers to it")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # NO_DEPRECATED_FEATURES: A feature or option deprecated in the file's edition
7
+ class NoDeprecatedFeatures < Rule
8
+ REASON = "Deprecated features will be removed in a later edition; the warning says what replaces them."
9
+ EXPLANATION = <<~TEXT
10
+ protoc warns when a file uses a feature or option its edition deprecates, with the replacement in the
11
+ message. With this rule on, that warning is reported once, under this rule.
12
+
13
+ edition = "2024";
14
+ option features.(pb.cpp).legacy_closed_enum = true;
15
+
16
+ Fix: follow the deprecation message.
17
+ TEXT
18
+ CLAIMS = ["V0507"].freeze
19
+
20
+ def check
21
+ context.warnings.each do |warning|
22
+ next unless warning.code == "V0507"
23
+ name, edition, reason = warning.message.match(/\A(.*) has been deprecated in edition (\w+): (.*)\z/m)&.captures
24
+ next unless name
25
+ spans = option_span(name.split(".").last) ||
26
+ warning.spans.map { |s| s.primary? ? s.with_label("deprecated") : s }
27
+ add("`#{name}` is deprecated since edition #{edition}", spans, help: reason)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # protoc reports against the element; the option that sets the
34
+ # feature is the more useful place to point
35
+ def option_span(feature)
36
+ pattern = /\bfeatures\b[^;=\]]*\b#{Regexp.escape(feature)}\b[^;,\]]*/
37
+ context.source.lines.each_with_index do |line, i|
38
+ match = line.match(pattern)
39
+ next unless match
40
+ before = line[0, match.begin(0)]
41
+ return nil if before.include?("\t")
42
+ from = before.bytesize + 1
43
+ return [Diagnostics::Span.new(i + 1, from, i + 1, from + match[0].rstrip.bytesize, label: "deprecated")]
44
+ end
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # NO_GROUPS: A proto2 `group` field
7
+ class NoGroups < Rule
8
+ REASON = "Groups are a proto2 relic most languages' tooling handles badly; a message field says the same thing."
9
+ EXPLANATION = <<~TEXT
10
+ A group declares a nested message and a field of that type at once, encoded with start and end tags
11
+ instead of a length. Most code generators and JSON mappings treat them as an oddity, proto3 does not
12
+ have them, and editions only keep the encoding (`features.message_encoding = DELIMITED`).
13
+
14
+ message SearchResponse {
15
+ repeated group Result = 1 {
16
+ optional string url = 2;
17
+ }
18
+ }
19
+
20
+ Fix: in a new schema, declare `message Result` and a `repeated Result result = 1;` field. For data already
21
+ written, switching changes the wire format; move the file to editions and keep the field DELIMITED.
22
+ TEXT
23
+
24
+ def check
25
+ context.each(:field) do |node, field|
26
+ next unless node.type == :TYPE_GROUP
27
+ report(node, :type, "field `#{field.name}` is a group", label: "group",
28
+ help: "declare `message #{field.message_type.name}` and a field of that type")
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # ONEOF_LOWER_SNAKE_CASE: A oneof name that is not lower_snake_case
7
+ class OneofLowerSnakeCase < Rule
8
+ REASON = "Oneof names become accessors and enum cases in generated code, derived on the assumption they are lower_snake_case."
9
+ EXPLANATION = <<~TEXT
10
+ oneof PaymentMethod { string card = 1; string iban = 2; }
11
+
12
+ Fix: `oneof payment_method { ... }`. `--fix` renames it when nothing else in the file mentions it.
13
+ TEXT
14
+
15
+ def check
16
+ context.each(:oneof) do |node, oneof|
17
+ next if Naming.lower_snake?(oneof.name)
18
+ wanted = Naming.lower_snake(oneof.name)
19
+ span = context.span(node, :name)
20
+ report(node, :name, "oneof `#{oneof.name}` is not lower_snake_case", span: span, label: "not lower_snake_case",
21
+ help: "name it `#{wanted}`", fix: rename(span, oneof.name, wanted))
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # PACKAGE_DEFINED: A file without a `package`
7
+ class PackageDefined < Rule
8
+ REASON = "A file without a package puts every type it declares in the global namespace, where any other file can clash with it."
9
+ EXPLANATION = <<~TEXT
10
+ Type names are global within a program. Without a package, `message User` is just `User`, and the first
11
+ other schema with a `User` breaks the build, or worse, the link.
12
+
13
+ syntax = "proto3";
14
+ message User {}
15
+
16
+ Fix: add `package acme.users.v1;` after the syntax line, matching the file's directory.
17
+ TEXT
18
+
19
+ def check
20
+ return unless context.file.package.empty?
21
+ span = context.span_at(Context::PATHS[:syntax]) || context.span_at(Context::PATHS[:edition])
22
+ add("file `#{context.name}` has no package", span ? [span.with_label("no `package` follows")] : [],
23
+ help: "add a `package` statement, e.g. `package acme.v1;`")
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # PACKAGE_DIRECTORY_MATCH: A file whose directory does not match its package
7
+ class PackageDirectoryMatch < Rule
8
+ REASON = "When `acme.users.v1` lives in `acme/users/v1/`, a type's file can be found from its name, by people and by tools."
9
+ EXPLANATION = <<~TEXT
10
+ Import paths and packages are separate namespaces; keeping them aligned is what lets you find
11
+ `acme.users.v1.User` in `acme/users/v1/`. The check looks at where the file is on disk, so it does not
12
+ depend on the include path.
13
+
14
+ // proto/users.proto
15
+ package acme.users.v1;
16
+
17
+ Fix: move the file to `proto/acme/users/v1/users.proto` (the include path being `proto`).
18
+ TEXT
19
+
20
+ def check
21
+ package = context.file.package
22
+ return if package.empty?
23
+ wanted = package.tr(".", "/")
24
+ dir = ::File.dirname(context.disk_path || context.name)
25
+ return if dir == wanted || dir.end_with?("/#{wanted}")
26
+ add("package `#{package}` is not in a `#{wanted}` directory",
27
+ [context.span_at(Context::PATHS[:package], label: "expects `#{wanted}/`")].compact,
28
+ help: "move the file into a `#{wanted}/` directory")
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # PACKAGE_LOWER_SNAKE_CASE: A package name that is not lower_snake_case
7
+ class PackageLowerSnakeCase < Rule
8
+ REASON = "Packages become namespaces in every language; lower_snake_case components map cleanly onto all of them."
9
+ EXPLANATION = <<~TEXT
10
+ `package Acme.UserService;` turns into namespaces, modules and directories, some case-sensitive and some
11
+ not.
12
+
13
+ package Acme.Users;
14
+
15
+ Fix: `package acme.users;`. Every file that names a type with the package has to change too.
16
+ TEXT
17
+
18
+ def check
19
+ package = context.file.package
20
+ return if package.empty? || package.split(".").all? { |part| Naming.lower_snake?(part) }
21
+ wanted = package.split(".").map { |part| Naming.lower_snake(part) }.join(".")
22
+ add("package `#{package}` is not lower_snake_case",
23
+ [context.span_at(Context::PATHS[:package], label: "not lower_snake_case")].compact, help: "use `#{wanted}`")
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # PACKAGE_SAME_DIRECTORY: Files in one directory that declare different packages
7
+ class PackageSameDirectory < Rule
8
+ REASON = "One package per directory keeps the package-to-directory mapping one to one."
9
+ EXPLANATION = <<~TEXT
10
+ Two packages in one directory mean the directory says nothing about where a type lives, and many
11
+ generators (Go's in particular) cannot put both in one output package.
12
+
13
+ // acme/a.proto
14
+ package acme.a;
15
+ // acme/b.proto
16
+ package acme.b;
17
+
18
+ Fix: move one of the files, or give them the same package.
19
+ TEXT
20
+
21
+ def check
22
+ dir = directory(context)
23
+ peers = context.all.select { |other| directory(other) == dir }
24
+ packages = peers.map { |other| other.file.package }.uniq
25
+ return if packages.size < 2 || context.file.package == packages.first
26
+ add("files in `#{dir}` declare packages `#{packages.join("`, `")}`",
27
+ [context.span_at(Context::PATHS[:package], label: "differs from `#{packages.first}`")].compact,
28
+ help: "keep one package per directory")
29
+ end
30
+
31
+ private
32
+
33
+ def directory(context)
34
+ ::File.dirname(context.disk_path || context.name)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # PACKAGE_VERSION_SUFFIX: A package without a version as its last component
7
+ class PackageVersionSuffix < Rule
8
+ REASON = "A version in the package (`acme.users.v1`) lets a breaking change live next to the old API instead of replacing it."
9
+ EXPLANATION = <<~TEXT
10
+ Once clients depend on a package, breaking changes have to go in a new one. Naming the first `v1` makes
11
+ `v2` the obvious next step. Accepted suffixes: `v1`, `v1beta1`, `v1alpha`, `v1test`, `v1p1beta1` and the
12
+ like.
13
+
14
+ package acme.users;
15
+
16
+ Fix: `package acme.users.v1;`.
17
+ TEXT
18
+
19
+ VERSION = /\Av\d+(?:(?:alpha|beta)\d*|test\w*|p\d+(?:alpha|beta)\d*)?\z/
20
+
21
+ def check
22
+ package = context.file.package
23
+ return if package.empty? || package.split(".").last.match?(VERSION)
24
+ add("package `#{package}` has no version suffix",
25
+ [context.span_at(Context::PATHS[:package], label: "no version")].compact,
26
+ help: "add one, e.g. `#{package}.v1`")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # RESERVED_NAMES_WITH_NUMBERS: A message or enum that reserves numbers but no names, or names but no numbers
7
+ class ReservedNamesWithNumbers < Rule
8
+ REASON = "A deleted field needs both its number and its name reserved: the number for the binary format, the name for JSON and text."
9
+ EXPLANATION = <<~TEXT
10
+ The binary format identifies a field by number, JSON and the text format by name. Reserving only the
11
+ number lets a new field take the old name, and old JSON then fills it; reserving only the name lets a new
12
+ field take the number, and old binary data fills it.
13
+
14
+ message User {
15
+ reserved 2;
16
+ string name = 1;
17
+ }
18
+
19
+ Fix: reserve both, `reserved 2; reserved "email";` (`reserved email;` in editions).
20
+ TEXT
21
+
22
+ def check
23
+ context.each(:message) { |node, message| check_element(node, message, "fields", 9, 10) }
24
+ context.each(:enum) { |node, enum| check_element(node, enum, "values", 4, 5) }
25
+ end
26
+
27
+ private
28
+
29
+ def check_element(node, element, what, range_field, name_field)
30
+ numbers = node.reserved_range
31
+ names = node.reserved_name
32
+ if !numbers.empty? && names.empty?
33
+ report(numbers[0], :number, "`#{element.name}` reserves #{what} by number but not by name",
34
+ label: "number only", help: "reserve the deleted #{what}' names too")
35
+ elsif numbers.empty? && !names.empty?
36
+ report(node, :name, "`#{element.name}` reserves #{what} by name but not by number",
37
+ span: context.span_at(node.path + [name_field]), label: "name only",
38
+ help: "reserve the deleted #{what}' numbers too")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # RPC_PASCAL_CASE: An rpc name that is not PascalCase
7
+ class RpcPascalCase < Rule
8
+ REASON = "RPCs become methods in every language's stubs, converted from PascalCase."
9
+ EXPLANATION = <<~TEXT
10
+ rpc get_user(GetUserRequest) returns (GetUserResponse);
11
+
12
+ Fix: `rpc GetUser(...)`.
13
+ TEXT
14
+
15
+ def check
16
+ context.each(:method) do |node, rpc|
17
+ next if Naming.pascal?(rpc.name)
18
+ report(node, :name, "rpc `#{rpc.name}` is not PascalCase", label: "not PascalCase",
19
+ help: "name it `#{Naming.pascal(rpc.name)}`")
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # RPC_REQUEST_RESPONSE_UNIQUE: A message used as the request or response of more than one rpc
7
+ class RpcRequestResponseUnique < Rule
8
+ REASON = "Requests and responses shared between RPCs will need to diverge, and then cannot."
9
+ EXPLANATION = <<~TEXT
10
+ Two RPCs that take the same request are tied together: a field one of them needs appears in the other's
11
+ API too. The same goes for responses, and for a message used as one RPC's request and another's response.
12
+
13
+ rpc GetUser(UserRequest) returns (User);
14
+ rpc DeleteUser(UserRequest) returns (User);
15
+
16
+ Fix: give each RPC its own `<Rpc>Request` and `<Rpc>Response`.
17
+ TEXT
18
+
19
+ def check
20
+ first = {}
21
+ context.each(:service) do |service_node, service|
22
+ service.rpcs.each_with_index do |rpc, i|
23
+ node = service_node.rpcs[i]
24
+ [[rpc.input_type, :input_type, "request"], [rpc.output_type, :output_type, "response"]].each do |type, kind, role|
25
+ if (earlier = first[type.full_name])
26
+ other_node, other_kind, other_rpc, other_role = earlier
27
+ report(node, kind, "`#{type.name}` is the #{role} of `#{rpc.name}` and already the #{other_role} of `#{other_rpc}`",
28
+ label: "used again", related: [[other_node, other_kind, "first used here"]],
29
+ help: "give `#{rpc.name}` a `#{Naming.pascal(rpc.name)}#{role.capitalize}` of its own")
30
+ else
31
+ first[type.full_name] = [node, kind, rpc.name, role]
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ module Rules
6
+ # RPC_REQUEST_STANDARD_NAME: An rpc whose request is not `<Rpc>Request`
7
+ class RpcRequestStandardName < Rule
8
+ REASON = "A request message of its own per RPC can grow fields without touching any other RPC."
9
+ EXPLANATION = <<~TEXT
10
+ An RPC that takes `User` or `google.protobuf.Empty` cannot add a parameter later without changing that
11
+ shared type for everyone. `GetUserRequest` (or `UserServiceGetUserRequest`) belongs to `GetUser` alone.
12
+
13
+ rpc GetUser(UserId) returns (User);
14
+
15
+ Fix: `rpc GetUser(GetUserRequest) returns (GetUserResponse);`.
16
+ TEXT
17
+
18
+ def check
19
+ context.each(:service) do |service_node, service|
20
+ service.rpcs.each_with_index do |rpc, i|
21
+ base = Naming.pascal(rpc.name)
22
+ wanted = ["#{base}Request", "#{service.name}#{base}Request"]
23
+ next if wanted.include?(rpc.input_type.name)
24
+ report(service_node.rpcs[i], :input_type, "request of `#{rpc.name}` is `#{rpc.input_type.name}`, not `#{wanted[0]}`",
25
+ label: "not `#{wanted[0]}`", help: "take a `#{wanted[0]}` message of its own")
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end