1 #region Copyright notice and license 2 3 // Copyright 2018 gRPC authors. 4 // 5 // Licensed under the Apache License, Version 2.0 (the "License"); 6 // you may not use this file except in compliance with the License. 7 // You may obtain a copy of the License at 8 // 9 // http://www.apache.org/licenses/LICENSE-2.0 10 // 11 // Unless required by applicable law or agreed to in writing, software 12 // distributed under the License is distributed on an "AS IS" BASIS, 13 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 // See the License for the specific language governing permissions and 15 // limitations under the License. 16 17 #endregion 18 19 using System; 20 using System.Collections.Generic; 21 using System.Text; 22 using System.Text.RegularExpressions; 23 using Microsoft.Build.Framework; 24 using Microsoft.Build.Utilities; 25 26 namespace Grpc.Tools 27 { 28 /// <summary> 29 /// Run Google proto compiler (protoc). 30 /// 31 /// After a successful run, the task reads the dependency file if specified 32 /// to be saved by the compiler, and returns its output files. 33 /// 34 /// This task (unlike PrepareProtoCompile) does not attempt to guess anything 35 /// about language-specific behavior of protoc, and therefore can be used for 36 /// any language outputs. 37 /// </summary> 38 public class ProtoCompile : ToolTask 39 { 40 /* 41 42 Usage: /home/kkm/work/protobuf/src/.libs/lt-protoc [OPTION] PROTO_FILES 43 Parse PROTO_FILES and generate output based on the options given: 44 -IPATH, --proto_path=PATH Specify the directory in which to search for 45 imports. May be specified multiple times; 46 directories will be searched in order. If not 47 given, the current working directory is used. 48 --version Show version info and exit. 49 -h, --help Show this text and exit. 50 --encode=MESSAGE_TYPE Read a text-format message of the given type 51 from standard input and write it in binary 52 to standard output. The message type must 53 be defined in PROTO_FILES or their imports. 54 --decode=MESSAGE_TYPE Read a binary message of the given type from 55 standard input and write it in text format 56 to standard output. The message type must 57 be defined in PROTO_FILES or their imports. 58 --decode_raw Read an arbitrary protocol message from 59 standard input and write the raw tag/value 60 pairs in text format to standard output. No 61 PROTO_FILES should be given when using this 62 flag. 63 --descriptor_set_in=FILES Specifies a delimited list of FILES 64 each containing a FileDescriptorSet (a 65 protocol buffer defined in descriptor.proto). 66 The FileDescriptor for each of the PROTO_FILES 67 provided will be loaded from these 68 FileDescriptorSets. If a FileDescriptor 69 appears multiple times, the first occurrence 70 will be used. 71 -oFILE, Writes a FileDescriptorSet (a protocol buffer, 72 --descriptor_set_out=FILE defined in descriptor.proto) containing all of 73 the input files to FILE. 74 --include_imports When using --descriptor_set_out, also include 75 all dependencies of the input files in the 76 set, so that the set is self-contained. 77 --include_source_info When using --descriptor_set_out, do not strip 78 SourceCodeInfo from the FileDescriptorProto. 79 This results in vastly larger descriptors that 80 include information about the original 81 location of each decl in the source file as 82 well as surrounding comments. 83 --dependency_out=FILE Write a dependency output file in the format 84 expected by make. This writes the transitive 85 set of input file paths to FILE 86 --error_format=FORMAT Set the format in which to print errors. 87 FORMAT may be 'gcc' (the default) or 'msvs' 88 (Microsoft Visual Studio format). 89 --print_free_field_numbers Print the free field numbers of the messages 90 defined in the given proto files. Groups share 91 the same field number space with the parent 92 message. Extension ranges are counted as 93 occupied fields numbers. 94 95 --plugin=EXECUTABLE Specifies a plugin executable to use. 96 Normally, protoc searches the PATH for 97 plugins, but you may specify additional 98 executables not in the path using this flag. 99 Additionally, EXECUTABLE may be of the form 100 NAME=PATH, in which case the given plugin name 101 is mapped to the given executable even if 102 the executable's own name differs. 103 --cpp_out=OUT_DIR Generate C++ header and source. 104 --csharp_out=OUT_DIR Generate C# source file. 105 --java_out=OUT_DIR Generate Java source file. 106 --javanano_out=OUT_DIR Generate Java Nano source file. 107 --js_out=OUT_DIR Generate JavaScript source. 108 --objc_out=OUT_DIR Generate Objective C header and source. 109 --php_out=OUT_DIR Generate PHP source file. 110 --python_out=OUT_DIR Generate Python source file. 111 --ruby_out=OUT_DIR Generate Ruby source file. 112 @<filename> Read options and filenames from file. If a 113 relative file path is specified, the file 114 will be searched in the working directory. 115 The --proto_path option will not affect how 116 this argument file is searched. Content of 117 the file will be expanded in the position of 118 @<filename> as in the argument list. Note 119 that shell expansion is not applied to the 120 content of the file (i.e., you cannot use 121 quotes, wildcards, escapes, commands, etc.). 122 Each line corresponds to a single argument, 123 even if it contains spaces. 124 */ 125 static string[] s_supportedGenerators = new[] { "cpp", "csharp", "java", 126 "javanano", "js", "objc", 127 "php", "python", "ruby" }; 128 129 static readonly TimeSpan s_regexTimeout = TimeSpan.FromMilliseconds(100); 130 131 static readonly List<ErrorListFilter> s_errorListFilters = new List<ErrorListFilter>() 132 { 133 // Example warning with location 134 //../Protos/greet.proto(19) : warning in column=5 : warning : When enum name is stripped and label is PascalCased (Zero), 135 // this value label conflicts with Zero. This will make the proto fail to compile for some languages, such as C#. 136 new ErrorListFilter 137 { 138 Pattern = new Regex( 139 pattern: "^(?'FILENAME'.+?)\\((?'LINE'\\d+)\\) ?: ?warning in column=(?'COLUMN'\\d+) ?: ?(?'TEXT'.*)", 140 options: RegexOptions.Compiled | RegexOptions.IgnoreCase, 141 matchTimeout: s_regexTimeout), 142 LogAction = (log, match) => 143 { 144 int.TryParse(match.Groups["LINE"].Value, out var line); 145 int.TryParse(match.Groups["COLUMN"].Value, out var column); 146 147 log.LogWarning( 148 subcategory: null, 149 warningCode: null, 150 helpKeyword: null, 151 file: match.Groups["FILENAME"].Value, 152 lineNumber: line, 153 columnNumber: column, 154 endLineNumber: 0, 155 endColumnNumber: 0, 156 message: match.Groups["TEXT"].Value); 157 } 158 }, 159 160 // Example error with location 161 //../Protos/greet.proto(14) : error in column=10: "name" is already defined in "Greet.HelloRequest". 162 new ErrorListFilter 163 { 164 Pattern = new Regex( 165 pattern: "^(?'FILENAME'.+?)\\((?'LINE'\\d+)\\) ?: ?error in column=(?'COLUMN'\\d+) ?: ?(?'TEXT'.*)", 166 options: RegexOptions.Compiled | RegexOptions.IgnoreCase, 167 matchTimeout: s_regexTimeout), 168 LogAction = (log, match) => 169 { 170 int.TryParse(match.Groups["LINE"].Value, out var line); 171 int.TryParse(match.Groups["COLUMN"].Value, out var column); 172 173 log.LogError( 174 subcategory: null, 175 errorCode: null, 176 helpKeyword: null, 177 file: match.Groups["FILENAME"].Value, 178 lineNumber: line, 179 columnNumber: column, 180 endLineNumber: 0, 181 endColumnNumber: 0, 182 message: match.Groups["TEXT"].Value); 183 } 184 }, 185 186 // Example warning without location 187 //../Protos/greet.proto: warning: Import google/protobuf/empty.proto but not used. 188 new ErrorListFilter 189 { 190 Pattern = new Regex( 191 pattern: "^(?'FILENAME'.+?): ?warning: ?(?'TEXT'.*)", 192 options: RegexOptions.Compiled | RegexOptions.IgnoreCase, 193 matchTimeout: s_regexTimeout), 194 LogAction = (log, match) => 195 { 196 log.LogWarning( 197 subcategory: null, 198 warningCode: null, 199 helpKeyword: null, 200 file: match.Groups["FILENAME"].Value, 201 lineNumber: 0, 202 columnNumber: 0, 203 endLineNumber: 0, 204 endColumnNumber: 0, 205 message: match.Groups["TEXT"].Value); 206 } 207 }, 208 209 // Example error without location 210 //../Protos/greet.proto: Import "google/protobuf/empty.proto" was listed twice. 211 new ErrorListFilter 212 { 213 Pattern = new Regex( 214 pattern: "^(?'FILENAME'.+?): ?(?'TEXT'.*)", 215 options: RegexOptions.Compiled | RegexOptions.IgnoreCase, 216 matchTimeout: s_regexTimeout), 217 LogAction = (log, match) => 218 { 219 log.LogError( 220 subcategory: null, 221 errorCode: null, 222 helpKeyword: null, 223 file: match.Groups["FILENAME"].Value, 224 lineNumber: 0, 225 columnNumber: 0, 226 endLineNumber: 0, 227 endColumnNumber: 0, 228 message: match.Groups["TEXT"].Value); 229 } 230 } 231 }; 232 233 /// <summary> 234 /// Code generator. 235 /// </summary> 236 [Required] 237 public string Generator { get; set; } 238 239 /// <summary> 240 /// Protobuf files to compile. 241 /// </summary> 242 [Required] 243 public ITaskItem[] Protobuf { get; set; } 244 245 /// <summary> 246 /// Directory where protoc dependency files are cached. If provided, dependency 247 /// output filename is autogenerated from source directory hash and file name. 248 /// Mutually exclusive with DependencyOut. 249 /// Switch: --dependency_out (with autogenerated file name). 250 /// </summary> 251 public string ProtoDepDir { get; set; } 252 253 /// <summary> 254 /// Dependency file full name. Mutually exclusive with ProtoDepDir. 255 /// Autogenerated file name is available in this property after execution. 256 /// Switch: --dependency_out. 257 /// </summary> 258 [Output] 259 public string DependencyOut { get; set; } 260 261 /// <summary> 262 /// The directories to search for imports. Directories will be searched 263 /// in order. If not given, the current working directory is used. 264 /// Switch: --proto_path. 265 /// </summary> 266 public string[] ProtoPath { get; set; } 267 268 /// <summary> 269 /// Generated code directory. The generator property determines the language. 270 /// Switch: --GEN-out= (for different generators GEN). 271 /// </summary> 272 [Required] 273 public string OutputDir { get; set; } 274 275 /// <summary> 276 /// Codegen options. See also OptionsFromMetadata. 277 /// Switch: --GEN_out= (for different generators GEN). 278 /// </summary> 279 public string[] OutputOptions { get; set; } 280 281 /// <summary> 282 /// Full path to the gRPC plugin executable. If specified, gRPC generation 283 /// is enabled for the files. 284 /// Switch: --plugin=protoc-gen-grpc= 285 /// </summary> 286 public string GrpcPluginExe { get; set; } 287 288 /// <summary> 289 /// Generated gRPC directory. The generator property determines the 290 /// language. If gRPC is enabled but this is not given, OutputDir is used. 291 /// Switch: --grpc_out= 292 /// </summary> 293 public string GrpcOutputDir { get; set; } 294 295 /// <summary> 296 /// gRPC Codegen options. See also OptionsFromMetadata. 297 /// --grpc_opt=opt1,opt2=val (comma-separated). 298 /// </summary> 299 public string[] GrpcOutputOptions { get; set; } 300 301 /// <summary> 302 /// List of files written in addition to generated outputs. Includes a 303 /// single item for the dependency file if written. 304 /// </summary> 305 [Output] 306 public ITaskItem[] AdditionalFileWrites { get; private set; } 307 308 /// <summary> 309 /// List of language files generated by protoc. Empty unless DependencyOut 310 /// or ProtoDepDir is set, since the file writes are extracted from protoc 311 /// dependency output file. 312 /// </summary> 313 [Output] 314 public ITaskItem[] GeneratedFiles { get; private set; } 315 316 // Hide this property from MSBuild, we should never use a shell script. 317 private new bool UseCommandProcessor { get; set; } 318 319 protected override string ToolName => Platform.IsWindows ? "protoc.exe" : "protoc"; 320 321 // Since we never try to really locate protoc.exe somehow, just try ToolExe 322 // as the full tool location. It will be either just protoc[.exe] from 323 // ToolName above if not set by the user, or a user-supplied full path. The 324 // base class will then resolve the former using system PATH. GenerateFullPathToTool()325 protected override string GenerateFullPathToTool() => ToolExe; 326 327 // Log protoc errors with the High priority (bold white in MsBuild, 328 // printed with -v:n, and shown in the Output windows in VS). 329 protected override MessageImportance StandardErrorLoggingImportance => MessageImportance.High; 330 331 // Called by base class to validate arguments and make them consistent. ValidateParameters()332 protected override bool ValidateParameters() 333 { 334 // Part of proto command line switches, must be lowercased. 335 Generator = Generator.ToLowerInvariant(); 336 if (!System.Array.Exists(s_supportedGenerators, g => g == Generator)) 337 { 338 Log.LogError("Invalid value for Generator='{0}'. Supported generators: {1}", 339 Generator, string.Join(", ", s_supportedGenerators)); 340 } 341 342 if (ProtoDepDir != null && DependencyOut != null) 343 { 344 Log.LogError("Properties ProtoDepDir and DependencyOut may not be both specified"); 345 } 346 347 if (Protobuf.Length > 1 && (ProtoDepDir != null || DependencyOut != null)) 348 { 349 Log.LogError("Proto compiler currently allows only one input when " + 350 "--dependency_out is specified (via ProtoDepDir or DependencyOut). " + 351 "Tracking issue: https://github.com/google/protobuf/pull/3959"); 352 } 353 354 // Use ProtoDepDir to autogenerate DependencyOut 355 if (ProtoDepDir != null) 356 { 357 DependencyOut = DepFileUtil.GetDepFilenameForProto(ProtoDepDir, Protobuf[0].ItemSpec); 358 } 359 360 if (GrpcPluginExe == null) 361 { 362 GrpcOutputOptions = null; 363 GrpcOutputDir = null; 364 } 365 else if (GrpcOutputDir == null) 366 { 367 // Use OutputDir for gRPC output if not specified otherwise by user. 368 GrpcOutputDir = OutputDir; 369 } 370 371 return !Log.HasLoggedErrors && base.ValidateParameters(); 372 } 373 374 // Protoc chokes on BOM, naturally. I would! 375 static readonly Encoding s_utf8WithoutBom = new UTF8Encoding(false); 376 protected override Encoding ResponseFileEncoding => s_utf8WithoutBom; 377 378 // Protoc takes one argument per line from the response file, and does not 379 // require any quoting whatsoever. Otherwise, this is similar to the 380 // standard CommandLineBuilder 381 class ProtocResponseFileBuilder 382 { 383 StringBuilder _data = new StringBuilder(1000); ToString()384 public override string ToString() => _data.ToString(); 385 386 // If 'value' is not empty, append '--name=value\n'. AddSwitchMaybe(string name, string value)387 public void AddSwitchMaybe(string name, string value) 388 { 389 if (!string.IsNullOrEmpty(value)) 390 { 391 _data.Append("--").Append(name).Append("=") 392 .Append(value).Append('\n'); 393 } 394 } 395 396 // Add switch with the 'values' separated by commas, for options. AddSwitchMaybe(string name, string[] values)397 public void AddSwitchMaybe(string name, string[] values) 398 { 399 if (values?.Length > 0) 400 { 401 _data.Append("--").Append(name).Append("=") 402 .Append(string.Join(",", values)).Append('\n'); 403 } 404 } 405 406 // Add a positional argument to the file data. AddArg(string arg)407 public void AddArg(string arg) 408 { 409 _data.Append(arg).Append('\n'); 410 } 411 }; 412 413 // Called by the base ToolTask to get response file contents. GenerateResponseFileCommands()414 protected override string GenerateResponseFileCommands() 415 { 416 var cmd = new ProtocResponseFileBuilder(); 417 cmd.AddSwitchMaybe(Generator + "_out", TrimEndSlash(OutputDir)); 418 cmd.AddSwitchMaybe(Generator + "_opt", OutputOptions); 419 cmd.AddSwitchMaybe("plugin=protoc-gen-grpc", GrpcPluginExe); 420 cmd.AddSwitchMaybe("grpc_out", TrimEndSlash(GrpcOutputDir)); 421 cmd.AddSwitchMaybe("grpc_opt", GrpcOutputOptions); 422 if (ProtoPath != null) 423 { 424 foreach (string path in ProtoPath) 425 { 426 cmd.AddSwitchMaybe("proto_path", TrimEndSlash(path)); 427 } 428 } 429 cmd.AddSwitchMaybe("dependency_out", DependencyOut); 430 cmd.AddSwitchMaybe("error_format", "msvs"); 431 foreach (var proto in Protobuf) 432 { 433 cmd.AddArg(proto.ItemSpec); 434 } 435 return cmd.ToString(); 436 } 437 438 // Protoc cannot digest trailing slashes in directory names, 439 // curiously under Linux, but not in Windows. TrimEndSlash(string dir)440 static string TrimEndSlash(string dir) 441 { 442 if (dir == null || dir.Length <= 1) 443 { 444 return dir; 445 } 446 string trim = dir.TrimEnd('/', '\\'); 447 // Do not trim the root slash, drive letter possible. 448 if (trim.Length == 0) 449 { 450 // Slashes all the way down. 451 return dir.Substring(0, 1); 452 } 453 if (trim.Length == 2 && dir.Length > 2 && trim[1] == ':') 454 { 455 // We have a drive letter and root, e. g. 'C:\' 456 return dir.Substring(0, 3); 457 } 458 return trim; 459 } 460 461 // Called by the base class to log tool's command line. 462 // 463 // Protoc command file is peculiar, with one argument per line, separated 464 // by newlines. Unwrap it for log readability into a single line, and also 465 // quote arguments, lest it look weird and so it may be copied and pasted 466 // into shell. Since this is for logging only, correct enough is correct. LogToolCommand(string cmd)467 protected override void LogToolCommand(string cmd) 468 { 469 var printer = new StringBuilder(1024); 470 471 // Print 'str' slice into 'printer', wrapping in quotes if contains some 472 // interesting characters in file names, or if empty string. The list of 473 // characters requiring quoting is not by any means exhaustive; we are 474 // just striving to be nice, not guaranteeing to be nice. 475 var quotable = new[] { ' ', '!', '$', '&', '\'', '^' }; 476 void PrintQuoting(string str, int start, int count) 477 { 478 bool wrap = count == 0 || str.IndexOfAny(quotable, start, count) >= 0; 479 if (wrap) printer.Append('"'); 480 printer.Append(str, start, count); 481 if (wrap) printer.Append('"'); 482 } 483 484 for (int ib = 0, ie; (ie = cmd.IndexOf('\n', ib)) >= 0; ib = ie + 1) 485 { 486 // First line only contains both the program name and the first switch. 487 // We can rely on at least the '--out_dir' switch being always present. 488 if (ib == 0) 489 { 490 int iep = cmd.IndexOf(" --"); 491 if (iep > 0) 492 { 493 PrintQuoting(cmd, 0, iep); 494 ib = iep + 1; 495 } 496 } 497 printer.Append(' '); 498 if (cmd[ib] == '-') 499 { 500 // Print switch unquoted, including '=' if any. 501 int iarg = cmd.IndexOf('=', ib, ie - ib); 502 if (iarg < 0) 503 { 504 // Bare switch without a '='. 505 printer.Append(cmd, ib, ie - ib); 506 continue; 507 } 508 printer.Append(cmd, ib, iarg + 1 - ib); 509 ib = iarg + 1; 510 } 511 // A positional argument or switch value. 512 PrintQuoting(cmd, ib, ie - ib); 513 } 514 515 base.LogToolCommand(printer.ToString()); 516 } 517 LogEventsFromTextOutput(string singleLine, MessageImportance messageImportance)518 protected override void LogEventsFromTextOutput(string singleLine, MessageImportance messageImportance) 519 { 520 foreach (ErrorListFilter filter in s_errorListFilters) 521 { 522 Match match = filter.Pattern.Match(singleLine); 523 524 if (match.Success) 525 { 526 filter.LogAction(Log, match); 527 return; 528 } 529 } 530 531 base.LogEventsFromTextOutput(singleLine, messageImportance); 532 } 533 534 // Main task entry point. Execute()535 public override bool Execute() 536 { 537 base.UseCommandProcessor = false; 538 539 bool ok = base.Execute(); 540 if (!ok) 541 { 542 return false; 543 } 544 545 // Read dependency output file from the compiler to retrieve the 546 // definitive list of created files. Report the dependency file 547 // itself as having been written to. 548 if (DependencyOut != null) 549 { 550 string[] outputs = DepFileUtil.ReadDependencyOutputs(DependencyOut, Log); 551 if (HasLoggedErrors) 552 { 553 return false; 554 } 555 556 GeneratedFiles = new ITaskItem[outputs.Length]; 557 for (int i = 0; i < outputs.Length; i++) 558 { 559 GeneratedFiles[i] = new TaskItem(outputs[i]); 560 } 561 AdditionalFileWrites = new ITaskItem[] { new TaskItem(DependencyOut) }; 562 } 563 564 return true; 565 } 566 567 class ErrorListFilter 568 { 569 public Regex Pattern { get; set; } 570 public Action<TaskLoggingHelper, Match> LogAction { get; set; } 571 } 572 }; 573 } 574