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,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Protostar
6
+ module Lint
7
+ # `protostar lint`, the command line
8
+ #
9
+ # protostar lint proto/ # every *.proto under proto/, recursively
10
+ # protostar lint acme/person.proto --format json
11
+ # protostar lint proto/ --rules default,documentation --except FIELD_NOT_REQUIRED
12
+ # protostar lint --explain FIELD_NOT_REQUIRED
13
+ #
14
+ # Exits 0 when clean, 1 with warnings (0 with --warnings-ok), 2 with
15
+ # errors, including mistakes on the command line.
16
+ class Command
17
+ USAGE = "Usage: protostar lint [OPTION] PROTO_FILES_OR_DIRS\n protostar lint --explain CODE_OR_RULE"
18
+ FORMATS = %w[text json github].freeze
19
+ EXPLAIN_HINT = "For more about a code or rule, try `protostar lint --explain CODE`."
20
+
21
+ def initialize(out, err)
22
+ @out = out
23
+ @err = err
24
+ end
25
+
26
+ # @param argv [Array<String>]
27
+ # @return [Integer] the exit status
28
+ def run(argv)
29
+ opts = {}
30
+ inputs = parser(opts).parse(argv)
31
+ return explain(opts[:explain]) if opts[:explain]
32
+ return list_rules if opts[:list]
33
+ raise Error, "Missing input file or directory.\n#{USAGE}" if inputs.empty?
34
+ config = opts[:config] ? Config.load(opts[:config]) : Config.find(inputs)
35
+ rules = Lint.select(opts[:rules] || config.rules, except: (opts[:except] || []) + config.except)
36
+ except = ((opts[:except] || []) + config.except).map { |e| Lint.rule_id(e) }
37
+ format = opts.fetch(:format, "text")
38
+ raise Error, "Unknown format: #{format} (#{FORMATS.join(", ")})" unless FORMATS.include?(format)
39
+ include, names = targets(inputs, opts[:include], config)
40
+ raise Error, "No .proto files in #{inputs.join(", ")}." if names.empty?
41
+ lint = -> { Runner.new(include_paths: include, rules: rules, except: except, directives: config.allow_comment_directives?) }
42
+ runner = lint.call
43
+ result = runner.run(names)
44
+ changed = fix(result, runner, write: opts[:fix], show: opts[:fix] || opts[:diff],
45
+ diff_io: (format == "text") ? nil : @err)
46
+ # what is left is what the fixed files say now
47
+ if changed
48
+ runner = lint.call
49
+ result = runner.run(names)
50
+ end
51
+ remaining = result.diagnostics
52
+ show(remaining, runner, format, opts, result)
53
+ return 2 if remaining.any?(&:error?)
54
+ return 1 if remaining.any?(&:warning?) && !opts[:warnings_ok]
55
+ 0
56
+ rescue Error, OptionParser::ParseError, Compiler::Error => e
57
+ @err.puts e.message
58
+ 2
59
+ end
60
+
61
+ private
62
+
63
+ def parser(opts)
64
+ OptionParser.new do |o|
65
+ o.banner = USAGE
66
+ o.on("-I", "--proto_path=PATH", "Directory to resolve names and imports against; repeatable, or colon-separated") do |v|
67
+ (opts[:include] ||= []).concat(v.split(":"))
68
+ end
69
+ o.on("--rules=LIST", "Sets (minimal, default, strict), categories or rule ids; default: default") do |v|
70
+ (opts[:rules] ||= []).concat(v.split(","))
71
+ end
72
+ o.on("--except=LIST", "Rule ids or codes to leave out") { |v| (opts[:except] ||= []).concat(v.split(",")) }
73
+ o.on("--format=FORMAT", "text (default), json (one object per line) or github (workflow annotations)") do |v|
74
+ opts[:format] = v
75
+ end
76
+ o.on("--color=WHEN", %w[auto always never], "auto (default: when stdout is a terminal and NO_COLOR is unset), always, never") do |v|
77
+ opts[:color] = v
78
+ end
79
+ o.on("--warnings-ok", "Exit 0 when there are warnings but no errors") { opts[:warnings_ok] = true }
80
+ o.on("--fix", "Apply the safe fixes (renames, a missing syntax line) and print the diff") { opts[:fix] = true }
81
+ o.on("--diff", "Print the diff --fix would apply, change nothing") { opts[:diff] = true }
82
+ o.on("--config=FILE", "Read FILE instead of looking for .protostar.yml") { |v| opts[:config] = v }
83
+ o.on("--explain=CODE", "Explain a code (L0301) or rule (FIELD_NOT_REQUIRED, W0101)") { |v| opts[:explain] = v }
84
+ o.on("--list-rules", "List the rules, their codes and sets") { opts[:list] = true }
85
+ end
86
+ end
87
+
88
+ def explain(key)
89
+ if (entry = Diagnostics::Codes[key])
90
+ @out.puts "#{entry.code}: #{entry.summary}", "", entry.explanation
91
+ return 0
92
+ end
93
+ id = Lint.rule_id(key)
94
+ raise Error, "No code or rule #{key}." unless id
95
+ rule = Lint.rule_class(id)
96
+ @out.puts "#{rule.code}: #{id} (#{rule.tier} set, #{rule.category})", "", rule.reason, "", rule.explanation
97
+ 0
98
+ end
99
+
100
+ def list_rules
101
+ RULES.each do |id, (code, tier, category)|
102
+ @out.puts format("%-6s %-30s %-8s %-14s %s", code, id, tier, category, Lint.rule_class(id).reason)
103
+ end
104
+ 0
105
+ end
106
+
107
+ # Include paths and the names of the files to lint. With -I, a path on
108
+ # disk is named relative to the include path above it. Without, a file's
109
+ # directory is its include path, and a directory is the include path of
110
+ # the files under it.
111
+ def targets(inputs, include, config)
112
+ roots = []
113
+ names = []
114
+ inputs.each do |input|
115
+ raise Error, "#{input}: No such file or directory" unless ::File.exist?(input)
116
+ files = if ::File.directory?(input)
117
+ Dir.glob("**/*.proto", base: input).sort.map { |f| ::File.join(input, f) }.reject { |f| config.ignored?(f) }
118
+ else
119
+ [input]
120
+ end
121
+ files.each do |file|
122
+ if include
123
+ name = virtual(file, include)
124
+ raise Error, "#{file}: not under any include path (-I)" unless name
125
+ else
126
+ root = ::File.directory?(input) ? input : ::File.dirname(input)
127
+ roots << root
128
+ name = Compiler.canonical_path(::File.expand_path(file).delete_prefix("#{::File.expand_path(root)}/"))
129
+ end
130
+ names << name
131
+ end
132
+ end
133
+ paths = include || roots.uniq { |r| ::File.expand_path(r) }
134
+ [paths, names.uniq]
135
+ end
136
+
137
+ def virtual(file, include)
138
+ path = ::File.expand_path(file)
139
+ include.each do |dir|
140
+ root = ::File.expand_path(dir)
141
+ return path.delete_prefix("#{root}/") if path.start_with?("#{root}/")
142
+ end
143
+ nil
144
+ end
145
+
146
+ # The diff goes to stdout with text output, to stderr when stdout is JSON
147
+ # or annotations
148
+ def fix(result, runner, write:, show:, diff_io: nil)
149
+ return false unless show
150
+ changes = Fixer.plan(result.diagnostics, text: ->(n) { runner.text(n) },
151
+ path: ->(n) { result.contexts.find { |c| c.name == n }&.disk_path })
152
+ changes.each do |change|
153
+ (diff_io || @out).write(Fixer.diff(change))
154
+ ::File.binwrite(change.path, change.after) if write && change.path
155
+ end
156
+ write && !changes.empty?
157
+ end
158
+
159
+ def show(diagnostics, runner, format, opts, result)
160
+ sources = Hash.new { |h, name| h[name] = (text = runner.text(name)) && Diagnostics::Source.new(text) }
161
+ case format
162
+ when "json"
163
+ diagnostics.each { |d| @out.puts Diagnostics::Formats.json(d, source: sources[d.file]) }
164
+ when "github"
165
+ diagnostics.each { |d| @out.puts Diagnostics::Formats.github(d, source: sources[d.file]) }
166
+ else
167
+ renderer = Diagnostics::Renderer.new(color: color?(opts[:color]), source: ->(d) { runner.text(d.file) })
168
+ diagnostics.each { |d| @out.puts renderer.render(d) }
169
+ @out.puts summary(diagnostics, result)
170
+ @out.puts EXPLAIN_HINT unless diagnostics.empty?
171
+ end
172
+ end
173
+
174
+ def summary(diagnostics, result)
175
+ errors = diagnostics.count(&:error?)
176
+ warnings = diagnostics.count(&:warning?)
177
+ fixable = diagnostics.count(&:fix)
178
+ files = result.names.size
179
+ text = "#{files} #{(files == 1) ? "file" : "files"}: #{count(errors, "error")}, #{count(warnings, "warning")}"
180
+ text += " (#{fixable} fixable with --fix)" if fixable > 0
181
+ text
182
+ end
183
+
184
+ def count(n, word)
185
+ "#{n} #{word}#{"s" unless n == 1}"
186
+ end
187
+
188
+ def color?(setting)
189
+ case setting
190
+ when "always" then true
191
+ when "never" then false
192
+ else CLI.color?(@out)
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ # The `lint:` section of `.protostar.yml`:
6
+ #
7
+ # lint:
8
+ # rules: [default] # sets, categories or rule ids
9
+ # except: [PACKAGE_VERSION_SUFFIX]
10
+ # ignore: ["vendor/**"] # relative to the file's directory
11
+ # allow_comment_directives: true
12
+ class Config
13
+ FILE_NAME = ".protostar.yml"
14
+ KEYS = %w[rules except ignore allow_comment_directives].freeze
15
+
16
+ # @return [Array<String>]
17
+ attr_reader :rules
18
+ # @return [Array<String>]
19
+ attr_reader :except
20
+ # @return [Array<String>] globs
21
+ attr_reader :ignore
22
+ # @return [String] the directory `ignore` globs are relative to
23
+ attr_reader :root
24
+ # @return [String, nil] the file read, nil for the defaults
25
+ attr_reader :file
26
+
27
+ # The first `.protostar.yml` next to an input (a directory or a file's
28
+ # directory), else in the current directory, else the defaults
29
+ # @param inputs [Array<String>]
30
+ # @return [Config]
31
+ def self.find(inputs, cwd: Dir.pwd)
32
+ dirs = inputs.map { |i| ::File.directory?(i) ? i : ::File.dirname(i) } + [cwd]
33
+ dirs.each do |dir|
34
+ path = ::File.join(dir, FILE_NAME)
35
+ return load(path) if ::File.file?(path)
36
+ end
37
+ new({}, root: cwd)
38
+ end
39
+
40
+ # @param path [String]
41
+ # @return [Config]
42
+ # @raise [Error] when it is not YAML, or the `lint:` section has an unknown key
43
+ def self.load(path)
44
+ require "yaml"
45
+ data = YAML.safe_load_file(path) || {}
46
+ raise Error, "#{path}: expected a mapping" unless data.is_a?(Hash)
47
+ section = data.fetch("lint", {}) || {}
48
+ raise Error, "#{path}: `lint` must be a mapping" unless section.is_a?(Hash)
49
+ unknown = section.keys - KEYS
50
+ raise Error, "#{path}: unknown lint setting #{unknown.join(", ")} (known: #{KEYS.join(", ")})" unless unknown.empty?
51
+ new(section, root: ::File.dirname(::File.expand_path(path)), file: path)
52
+ rescue Psych::Exception => e
53
+ raise Error, "#{path}: #{e.message}"
54
+ end
55
+
56
+ def initialize(section, root:, file: nil)
57
+ @rules = list(section["rules"]) || ["default"]
58
+ @except = list(section["except"]) || []
59
+ @ignore = list(section["ignore"]) || []
60
+ @directives = section.fetch("allow_comment_directives", true) != false
61
+ @root = root
62
+ @file = file
63
+ end
64
+
65
+ def allow_comment_directives?
66
+ @directives
67
+ end
68
+
69
+ # @param path [String] a file found while walking a directory
70
+ # @return [Boolean] whether an `ignore` glob matches it or a directory above it
71
+ def ignored?(path)
72
+ return false if @ignore.empty?
73
+ rel = relative(::File.expand_path(path))
74
+ candidates = [rel]
75
+ dir = ::File.dirname(rel)
76
+ until dir == "." || dir == "/"
77
+ candidates << dir
78
+ dir = ::File.dirname(dir)
79
+ end
80
+ @ignore.any? do |glob|
81
+ candidates.any? { |c| ::File.fnmatch?(glob, c, ::File::FNM_PATHNAME | ::File::FNM_EXTGLOB) }
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def list(value)
88
+ return nil if value.nil?
89
+ Array(value).flat_map { |v| v.to_s.split(",") }.map(&:strip).reject(&:empty?)
90
+ end
91
+
92
+ def relative(path)
93
+ root = ::File.expand_path(@root)
94
+ path.start_with?("#{root}/") ? path.delete_prefix("#{root}/") : path
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ # What a rule sees of one compiled file: the frozen {Schema::File}, the
6
+ # AST with its recorded positions, the source locations with their
7
+ # comments, the text, and the compiler's warnings for it.
8
+ class Context
9
+ # Source paths of `FileDescriptorProto` fields the rules point at
10
+ PATHS = {syntax: [12], edition: [14], package: [2]}.freeze
11
+
12
+ # @return [String] the file name as imported
13
+ attr_reader :name
14
+ # @return [String, nil] the file on disk, as diagnostics print it
15
+ attr_reader :path
16
+ # @return [String, nil] the file on disk, absolute
17
+ attr_reader :disk_path
18
+ # @return [Schema::File]
19
+ attr_reader :file
20
+ # @return [Compiler::AST::File]
21
+ attr_reader :ast
22
+ # @return [String] the file's bytes
23
+ attr_reader :text
24
+ # @return [Array<Compiler::Diagnostic>] the compiler's warnings for the file
25
+ attr_reader :warnings
26
+ # @return [Array<Context>] every file of the lint run, this one included
27
+ attr_reader :all
28
+
29
+ # @param linker [Compiler::Linker] the linker that built the file
30
+ def initialize(name:, path:, disk_path:, linker:, text:, warnings:, all:)
31
+ @name = name
32
+ @path = path
33
+ @disk_path = disk_path
34
+ @linker = linker
35
+ @file = linker.file
36
+ @ast = linker.ast
37
+ @text = text
38
+ @warnings = warnings
39
+ @all = all
40
+ @extents = {}
41
+ @ast.locations.each do |loc|
42
+ span = loc.span
43
+ next if span.size < 3
44
+ finish = (span.size == 4) ? [span[2], span[3]] : [span[0], span[2]]
45
+ key = [span[0], span[1]]
46
+ @extents[key] = finish if @extents[key].nil? || (finish <=> @extents[key]) < 0
47
+ end
48
+ @by_path = {}
49
+ @linker.locations.each { |loc| @by_path[loc.path] ||= loc }
50
+ end
51
+
52
+ # @return [Diagnostics::Source]
53
+ def source
54
+ @source ||= Diagnostics::Source.new(@text)
55
+ end
56
+
57
+ # Every element of a kind, as protoc visits them, without the map entry
58
+ # messages and synthetic oneofs the compiler made up
59
+ # @param kind [Symbol] :message, :field, :oneof, :enum, :enum_value, :service or :method
60
+ # @yieldparam node [Compiler::AST::Node]
61
+ # @yieldparam descriptor [Schema::Descriptor]
62
+ def each(kind)
63
+ @linker.each_element do |node, k|
64
+ next unless k == kind
65
+ descriptor = @linker.descriptor_of(node)
66
+ next if synthetic?(node, kind, descriptor)
67
+ yield node, descriptor
68
+ end
69
+ end
70
+
71
+ # @return [Schema::Descriptor] what the compiler built for a node
72
+ def descriptor(node)
73
+ @linker.descriptor_of(node)
74
+ end
75
+
76
+ # @param node [Compiler::AST::Node]
77
+ # @param kind [Symbol] a position kind the parser recorded: :name, :number, :type, ...
78
+ # @return [Diagnostics::Span, nil] from the position to the end of the element that starts there
79
+ def span(node, kind, label: nil, primary: true)
80
+ line, column = node.position(kind)
81
+ # oneofs record no position; every element's name is field 1 of its descriptor
82
+ return span_at(node.path + [1], label: label, primary: primary) if line.nil? && kind == :name && node.path
83
+ return nil unless line
84
+ end_line, end_column = @extents[[line, column]]
85
+ Diagnostics::Span.new(line + 1, column + 1, end_line && end_line + 1, end_column && end_column + 1,
86
+ label: label, primary: primary)
87
+ end
88
+
89
+ # @param path [Array<Integer>] a source path, e.g. [3, 0] for the first import
90
+ # @return [Diagnostics::Span, nil] the span of the first location with that path
91
+ def span_at(path, label: nil, primary: true)
92
+ loc = @by_path[path]
93
+ return nil unless loc
94
+ s = loc.span
95
+ end_line = (s.size == 4) ? s[2] : s[0]
96
+ Diagnostics::Span.new(s[0] + 1, s[1] + 1, end_line + 1, s.last + 1, label: label, primary: primary)
97
+ end
98
+
99
+ # @return [String, nil] the leading comment of an element, or its trailing
100
+ # one, stripped; nil when it has none
101
+ def comment(node)
102
+ loc = node.path && @by_path[node.path]
103
+ return nil unless loc
104
+ [loc.leading_comments, loc.trailing_comments].compact.map(&:strip).find { |c| !c.empty? }
105
+ end
106
+
107
+ # Whether renaming `from` to `to` is safe to do mechanically: `from` is
108
+ # written once in the file, as the declaration, and `to` nowhere
109
+ def renamable?(from, to)
110
+ @words ||= @text.b.scan(/[A-Za-z_][A-Za-z0-9_]*/).tally
111
+ @words[from.b] == 1 && !@words.key?(to.b)
112
+ end
113
+
114
+ private
115
+
116
+ def synthetic?(node, kind, descriptor)
117
+ case kind
118
+ when :message then descriptor.map_entry?
119
+ when :oneof then descriptor.synthetic?
120
+ when :field then descriptor.containing_message&.map_entry? || false
121
+ else false
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ # Comments that switch rules off for part of a file:
6
+ #
7
+ # // protostar-lint: disable-next-line FIELD_NOT_REQUIRED
8
+ # required string id = 1;
9
+ #
10
+ # // protostar-lint: disable COMMENT_FIELD, COMMENT_MESSAGE
11
+ # ...
12
+ # // protostar-lint: enable COMMENT_FIELD, COMMENT_MESSAGE
13
+ #
14
+ # A directive names rule ids or codes (a compiler warning's code works
15
+ # too), or none for every rule. `disable` without `enable` runs to the end
16
+ # of the file. Directives are read from the text line by line.
17
+ class Directives
18
+ PATTERN = %r{//\s*protostar-lint:\s*(disable-next-line|disable|enable)\b([^\n]*)}
19
+
20
+ # @param text [String] the file's bytes
21
+ def initialize(text)
22
+ @next_line = Hash.new { |h, k| h[k] = [] }
23
+ @ranges = []
24
+ open = {}
25
+ lines = text.b.split("\n", -1)
26
+ lines.each_with_index do |line, i|
27
+ match = PATTERN.match(line)
28
+ next unless match
29
+ names = match[2].split(/[\s,]+/).reject(&:empty?).map(&:upcase)
30
+ names = [:all] if names.empty?
31
+ number = i + 1
32
+ case match[1]
33
+ when "disable-next-line" then names.each { |n| @next_line[number + 1] << n }
34
+ when "disable" then names.each { |n| open[n] ||= number }
35
+ when "enable"
36
+ ((names == [:all]) ? open.keys : names).each do |n|
37
+ start = open.delete(n) || open.delete(:all)
38
+ @ranges << [n, start, number] if start
39
+ end
40
+ end
41
+ end
42
+ open.each { |n, start| @ranges << [n, start, lines.size + 1] }
43
+ end
44
+
45
+ # @param diagnostic [Compiler::Diagnostic]
46
+ # @return [Boolean] whether a directive switches it off where it starts
47
+ def suppressed?(diagnostic)
48
+ line = diagnostic.line
49
+ return false unless line
50
+ keys = [diagnostic.rule, diagnostic.code, :all].compact
51
+ return true if @next_line.key?(line) && @next_line[line].any? { |n| keys.include?(n) }
52
+ @ranges.any? { |n, from, to| keys.include?(n) && line > from && line < to }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protostar
4
+ module Lint
5
+ # Applies the {Diagnostics::Fix}es lint findings carry, file by file. An
6
+ # edit that overlaps one already taken is left out (the finding stays
7
+ # unfixed), so the result never depends on two fixes agreeing.
8
+ class Fixer
9
+ CONTEXT = 3
10
+
11
+ # The fixes for one file
12
+ class Change
13
+ # @return [String] the file name as imported
14
+ attr_reader :name
15
+ # @return [String, nil] where it is on disk
16
+ attr_reader :path
17
+ # @return [String]
18
+ attr_reader :before
19
+ # @return [String]
20
+ attr_reader :after
21
+ # @return [Array<Compiler::Diagnostic>] the findings fixed
22
+ attr_reader :fixed
23
+
24
+ def initialize(name, path, before, after, fixed)
25
+ @name = name
26
+ @path = path
27
+ @before = before
28
+ @after = after
29
+ @fixed = fixed.freeze
30
+ freeze
31
+ end
32
+ end
33
+
34
+ # @param diagnostics [Array<Compiler::Diagnostic>]
35
+ # @param text [#call] the text of a file, by name
36
+ # @param path [#call] where a file is on disk, by name, or nil
37
+ # @return [Array<Change>] one per file with a fix
38
+ def self.plan(diagnostics, text:, path:)
39
+ diagnostics.select(&:fix).group_by(&:file).filter_map do |name, found|
40
+ before = text.call(name)
41
+ next unless before
42
+ taken = []
43
+ fixed = found.select do |d|
44
+ edits = d.fix.edits
45
+ next false if edits.any? { |e| taken.any? { |t| overlap?(e, t) } }
46
+ taken.concat(edits)
47
+ true
48
+ end
49
+ after = Diagnostics::Source.new(before).apply(before, taken)
50
+ Change.new(name, path.call(name), before, after, fixed)
51
+ end
52
+ end
53
+
54
+ def self.overlap?(a, b)
55
+ a_start = [a.line, a.column]
56
+ a_end = [a.end_line, a.end_column]
57
+ b_start = [b.line, b.column]
58
+ b_end = [b.end_line, b.end_column]
59
+ return a_start == b_start if a_start == a_end || b_start == b_end
60
+ (a_start <=> b_end) < 0 && (b_start <=> a_end) < 0
61
+ end
62
+ private_class_method :overlap?
63
+
64
+ # @param change [Change]
65
+ # @return [String] a unified diff, as `diff -u` prints it
66
+ def self.diff(change)
67
+ old_lines = change.before.split(/(?<=\n)/)
68
+ new_lines = change.after.split(/(?<=\n)/)
69
+ label = change.path || change.name
70
+ old_label, new_label = label.start_with?("/") ? [label, label] : ["a/#{label}", "b/#{label}"]
71
+ out = "--- #{old_label}\n+++ #{new_label}\n"
72
+ hunks(old_lines, new_lines).each do |os, oe, ns, ne|
73
+ out << "@@ -#{range(os, oe)} +#{range(ns, ne)} @@\n"
74
+ i = os
75
+ j = ns
76
+ while i < oe || j < ne
77
+ if i < oe && j < ne && old_lines[i] == new_lines[j]
78
+ out << " " << terminated(old_lines[i])
79
+ i += 1
80
+ j += 1
81
+ else
82
+ changed_old, changed_new = block(old_lines, new_lines, i, j, oe, ne)
83
+ changed_old.each { |l| out << "-" << terminated(l) }
84
+ changed_new.each { |l| out << "+" << terminated(l) }
85
+ i += changed_old.size
86
+ j += changed_new.size
87
+ end
88
+ end
89
+ end
90
+ out
91
+ end
92
+
93
+ # The lines that differ, as aligned runs: lint's edits change lines in
94
+ # place or insert whole lines, so a run ends where the two sides meet again
95
+ def self.block(old_lines, new_lines, i, j, oe, ne)
96
+ (0..[oe - i, ne - j].max).each do |skip|
97
+ (0..skip).each do |a|
98
+ b = skip - a
99
+ next if i + a > oe || j + b > ne
100
+ if (i + a == oe && j + b == ne) || (i + a < oe && j + b < ne && old_lines[i + a] == new_lines[j + b])
101
+ return [old_lines[i, a], new_lines[j, b]] if a + b > 0
102
+ end
103
+ end
104
+ end
105
+ [old_lines[i...oe], new_lines[j...ne]]
106
+ end
107
+ private_class_method :block
108
+
109
+ # Hunks as [old start, old end, new start, new end], 0-based, ends exclusive
110
+ def self.hunks(old_lines, new_lines)
111
+ prefix = 0
112
+ prefix += 1 while prefix < old_lines.size && prefix < new_lines.size && old_lines[prefix] == new_lines[prefix]
113
+ suffix = 0
114
+ while suffix < old_lines.size - prefix && suffix < new_lines.size - prefix &&
115
+ old_lines[-1 - suffix] == new_lines[-1 - suffix]
116
+ suffix += 1
117
+ end
118
+ return [] if prefix == old_lines.size && prefix == new_lines.size
119
+ changes = []
120
+ i = prefix
121
+ j = prefix
122
+ oe = old_lines.size - suffix
123
+ ne = new_lines.size - suffix
124
+ while i < oe || j < ne
125
+ if i < oe && j < ne && old_lines[i] == new_lines[j]
126
+ i += 1
127
+ j += 1
128
+ next
129
+ end
130
+ a, b = block(old_lines, new_lines, i, j, oe, ne)
131
+ changes << [i, i + a.size, j, j + b.size]
132
+ i += a.size
133
+ j += b.size
134
+ end
135
+ merged = []
136
+ changes.each do |os, oe2, ns, ne2|
137
+ if merged.any? && os - merged.last[1] <= 2 * CONTEXT
138
+ merged.last[1] = oe2
139
+ merged.last[3] = ne2
140
+ else
141
+ merged << [os, oe2, ns, ne2]
142
+ end
143
+ end
144
+ merged.map do |os, oe2, ns, ne2|
145
+ before = [CONTEXT, os].min
146
+ after = [CONTEXT, old_lines.size - oe2].min
147
+ [os - before, oe2 + after, ns - before, ne2 + after]
148
+ end
149
+ end
150
+ private_class_method :hunks
151
+
152
+ def self.range(start, finish)
153
+ count = finish - start
154
+ first = count.zero? ? start : start + 1
155
+ (count == 1) ? first.to_s : "#{first},#{count}"
156
+ end
157
+ private_class_method :range
158
+
159
+ def self.terminated(line)
160
+ line.end_with?("\n") ? line : "#{line}\n\\n"
161
+ end
162
+ private_class_method :terminated
163
+ end
164
+ end
165
+ end