Repository: uber/prototool (go)

Issue:
Allow Java Packages with tld prefixes other than COM
We've just started using prototool on a new project, and trying to adopt the `uber2` linting rules. We're running into a violation on the `FILE_OPTIONS_EQUAL_JAVA_PACKAGE_COM_PREFIX` rule, because our reverse dns is `au.com.<company>`, not `com.<company>`. I imagine that this would be a fairly common case for non-US based/non-global companies.

Ideally we'd like to be able to use the `uber2` ruleset without any rule exceptions, but mandating that Java packages start with `com` requires this rule to be disabled. If other tlds were supported, or the package prefix was overridable, this would allow the `uber2` ruleset to be used without exceptions.

I'm uncertain whether keeping the ability to use protos defined by other parties with different tlds would be required if overriding the package prefix.

Pull request description:
Allow java_package prefix to be specified
Fixes #324.

This allows one to specify the following in their configuration file:

```yaml
lint:
  java_package_prefix: au.com
```

This will affect `lint, format --fix, create` by using this prefix instead of `com`.

```proto
package foo.v1;

...

option java_package = "au.com.foo.v1";
```

Patch:
diff --git a/.gitignore b/.gitignore
index fde049e..e98f46b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@
 /internal/cmd/testdata/create/version2three/b/c/bar/v1beta1/baz.proto
 /internal/cmd/testdata/create/version2three/baz.proto
 /internal/cmd/testdata/create/version2four/baz.proto
+/internal/cmd/testdata/create/version2five/baz.proto
 /main
 /prototool.iml
 /release
diff --git a/etc/config/example/prototool.yaml b/etc/config/example/prototool.yaml
index 3b928a7..e784118 100644
--- a/etc/config/example/prototool.yaml
+++ b/etc/config/example/prototool.yaml
@@ -1,4 +1,6 @@
 # Paths to exclude when searching for Protobuf files.
+# These can either be file or directory names.
+# If there is a directory name, that directory and all sub-directories will be excluded.
 excludes:
   - path/to/a
   - path/to/b/file.proto
@@ -44,6 +46,8 @@ lint:
   group: uber2
 
   # Linter files to ignore.
+  # These can either be file or directory names.
+  # If there is a directory name, that directory and all sub-directories will be ignored.
   ignores:
     - id: RPC_NAMES_CAMEL_CASE
       files:
@@ -51,7 +55,7 @@ lint:
         - path/to/bar.proto
     - id: SYNTAX_PROTO3
       files:
-        - path/to/foo.proto
+        - path/to/dir
 
   # Linter rules.
   # Run prototool lint --list-all-linters to see all available linters.
@@ -93,6 +97,10 @@ lint:
       // Acme, Inc. (c) 2019
       //
     is_commented: true
+  # Override the default java_package file option prefix of "com".
+  # If this is set, this will affect lint, create, and format --fix to use.
+  # this prefix instead of "com".
+  java_package_prefix: au.com
 
 # Code generation directives.
 generate:
diff --git a/internal/cfginit/cfginit.go b/internal/cfginit/cfginit.go
index dc22642..d9dc736 100644
--- a/internal/cfginit/cfginit.go
+++ b/internal/cfginit/cfginit.go
@@ -29,6 +29,8 @@ import (
 )
 
 var tmpl = template.Must(template.New("tmpl").Parse(`# Paths to exclude when searching for Protobuf files.
+# These can either be file or directory names.
+# If there is a directory name, that directory and all sub-directories will be excluded.
 {{.V}}excludes:
 {{.V}}  - path/to/a
 {{.V}}  - path/to/b/file.proto
@@ -74,6 +76,8 @@ protoc:
 {{.V}}  group: uber2
 
   # Linter files to ignore.
+  # These can either be file or directory names.
+  # If there is a directory name, that directory and all sub-directories will be ignored.
 {{.V}}  ignores:
 {{.V}}    - id: RPC_NAMES_CAMEL_CASE
 {{.V}}      files:
@@ -81,7 +85,7 @@ protoc:
 {{.V}}        - path/to/bar.proto
 {{.V}}    - id: SYNTAX_PROTO3
 {{.V}}      files:
-{{.V}}        - path/to/foo.proto
+{{.V}}        - path/to/dir
 
   # Linter rules.
   # Run prototool lint --list-all-linters to see all available linters.
@@ -123,6 +127,10 @@ protoc:
 {{.V}}      // Acme, Inc. (c) 2019
 {{.V}}      //
 {{.V}}    is_commented: true
+  # Override the default java_package file option prefix of "com".
+  # If this is set, this will affect lint, create, and format --fix to use.
+  # this prefix instead of "com".
+{{.V}}  java_package_prefix: au.com
 
 # Code generation directives.
 {{.V}}generate:
diff --git a/internal/create/handler.go b/internal/create/handler.go
index 31ff4fa..4424077 100644
--- a/internal/create/handler.go
+++ b/internal/create/handler.go
@@ -156,6 +156,10 @@ func (h *handler) create(filePath string) error {
 	if err != nil {
 		return err
 	}
+	javaPackagePrefix, err := h.getJavaPackagePrefix(filePath)
+	if err != nil {
+		return err
+	}
 	var data []byte
 	if isV2 {
 		data, err = getDataV2(
@@ -165,7 +169,7 @@ func (h *handler) create(filePath string) error {
 				CSharpNamespace:    protostrs.CSharpNamespace(pkg),
 				GoPkg:              protostrs.GoPackageV2(pkg),
 				JavaOuterClassname: protostrs.JavaOuterClassname(filePath),
-				JavaPkg:            protostrs.JavaPackage(pkg),
+				JavaPkg:            protostrs.JavaPackagePrefixOverride(pkg, javaPackagePrefix),
 				OBJCClassPrefix:    protostrs.OBJCClassPrefix(pkg),
 				PHPNamespace:       protostrs.PHPNamespace(pkg),
 			},
@@ -180,7 +184,7 @@ func (h *handler) create(filePath string) error {
 				Pkg:                pkg,
 				GoPkg:              protostrs.GoPackage(pkg),
 				JavaOuterClassname: protostrs.JavaOuterClassname(filePath),
-				JavaPkg:            protostrs.JavaPackage(pkg),
+				JavaPkg:            protostrs.JavaPackagePrefixOverride(pkg, javaPackagePrefix),
 			},
 		)
 		if err != nil {
@@ -191,12 +195,7 @@ func (h *handler) create(filePath string) error {
 }
 
 func (h *handler) isV2(filePath string) (bool, error) {
-	absFilePath, err := filepath.Abs(filePath)
-	if err != nil {
-		return false, err
-	}
-	absDirPath := filepath.Dir(absFilePath)
-	config, err := h.getConfig(absDirPath)
+	config, err := h.getConfig(filePath)
 	if err != nil {
 		return false, err
 	}
@@ -208,12 +207,19 @@ func (h *handler) isV2(filePath string) (bool, error) {
 }
 
 func (h *handler) getFileHeader(filePath string) (string, error) {
-	absFilePath, err := filepath.Abs(filePath)
+	config, err := h.getConfig(filePath)
 	if err != nil {
 		return "", err
 	}
-	absDirPath := filepath.Dir(absFilePath)
-	config, err := h.getConfig(absDirPath)
+	// no config file found
+	if config.DirPath == "" {
+		return "", nil
+	}
+	return config.Lint.FileHeader, nil
+}
+
+func (h *handler) getJavaPackagePrefix(filePath string) (string, error) {
+	config, err := h.getConfig(filePath)
 	if err != nil {
 		return "", err
 	}
@@ -221,19 +227,14 @@ func (h *handler) getFileHeader(filePath string) (string, error) {
 	if config.DirPath == "" {
 		return "", nil
 	}
-	return config.Lint.FileHeader, nil
+	return config.Lint.JavaPackagePrefix, nil
 }
 
 func (h *handler) getPkg(filePath string, defaultPackage string) (string, error) {
 	if h.pkg != "" {
 		return h.pkg, nil
 	}
-	absFilePath, err := filepath.Abs(filePath)
-	if err != nil {
-		return "", err
-	}
-	absDirPath := filepath.Dir(absFilePath)
-	config, err := h.getConfig(absDirPath)
+	config, err := h.getConfig(filePath)
 	if err != nil {
 		return "", err
 	}
@@ -241,6 +242,11 @@ func (h *handler) getPkg(filePath string, defaultPackage string) (string, error)
 	if config.DirPath == "" {
 		return defaultPackage, nil
 	}
+	absFilePath, err := filepath.Abs(filePath)
+	if err != nil {
+		return "", err
+	}
+	absDirPath := filepath.Dir(absFilePath)
 	// we need to get all the matching directories and then choose the longest
 	// ie if you have a, a/b, we choose a/b
 	var longestCreateDirPath string
@@ -280,7 +286,12 @@ func (h *handler) getPkg(filePath string, defaultPackage string) (string, error)
 	return getPkgFromRel(rel, "", defaultPackage), nil
 }
 
-func (h *handler) getConfig(absDirPath string) (settings.Config, error) {
+func (h *handler) getConfig(filePath string) (settings.Config, error) {
+	absFilePath, err := filepath.Abs(filePath)
+	if err != nil {
+		return settings.Config{}, err
+	}
+	absDirPath := filepath.Dir(absFilePath)
 	if h.configData != "" {
 		return h.configProvider.GetForData(absDirPath, h.configData)
 	}
diff --git a/internal/exec/runner.go b/internal/exec/runner.go
index 214b8ee..2f80147 100644
--- a/internal/exec/runner.go
+++ b/internal/exec/runner.go
@@ -390,10 +390,10 @@ func (r *runner) Format(args []string, overwrite, diffMode, lintMode, fixFlag bo
 	if _, err := r.compile(false, false, false, meta); err != nil {
 		return err
 	}
-	return r.format(overwrite, diffMode, lintMode, getFormatFixValue(fixFlag, meta), getFormatFileHeaderValue(fixFlag, meta), meta)
+	return r.format(overwrite, diffMode, lintMode, getFormatFixValue(fixFlag, meta), getFormatFileHeaderValue(fixFlag, meta), getFormatJavaPackagePrefixValue(fixFlag, meta), meta)
 }
 
-func (r *runner) format(overwrite, diffMode, lintMode bool, fix int, fileHeader string, meta *meta) error {
+func (r *runner) format(overwrite, diffMode, lintMode bool, fix int, fileHeader string, javaPackagePrefix string, meta *meta) error {
 	success := true
 	for dirPath, protoFiles := range meta.ProtoSet.DirPathToFiles {
 		// skip those files not under the directory
@@ -401,7 +401,7 @@ func (r *runner) format(overwrite, diffMode, lintMode bool, fix int, fileHeader
 			continue
 		}
 		for _, protoFile := range protoFiles {
-			fileSuccess, err := r.formatFile(overwrite, diffMode, lintMode, fix, fileHeader, meta, protoFile)
+			fileSuccess, err := r.formatFile(overwrite, diffMode, lintMode, fix, fileHeader, javaPackagePrefix, meta, protoFile)
 			if err != nil {
 				return err
 			}
@@ -419,7 +419,7 @@ func (r *runner) format(overwrite, diffMode, lintMode bool, fix int, fileHeader
 // return true if there was no unexpected diff and we should exit with 0
 // return false if we should exit with non-zero
 // if false and nil error, we will return an ExitError outside of this function
-func (r *runner) formatFile(overwrite bool, diffMode bool, lintMode bool, fix int, fileHeader string, meta *meta, protoFile *file.ProtoFile) (bool, error) {
+func (r *runner) formatFile(overwrite bool, diffMode bool, lintMode bool, fix int, fileHeader string, javaPackagePrefix string, meta *meta, protoFile *file.ProtoFile) (bool, error) {
 	absSingleFilename, err := file.AbsClean(meta.SingleFilename)
 	if err != nil {
 		return false, err
@@ -432,7 +432,7 @@ func (r *runner) formatFile(overwrite bool, diffMode bool, lintMode bool, fix in
 	if err != nil {
 		return false, err
 	}
-	data, failures, err := r.newTransformer(fix, fileHeader).Transform(protoFile.Path, input)
+	data, failures, err := r.newTransformer(fix, fileHeader, javaPackagePrefix).Transform(protoFile.Path, input)
 	if err != nil {
 		return false, err
 	}
@@ -485,7 +485,7 @@ func (r *runner) All(args []string, disableFormat, disableLint, fixFlag bool) er
 		return err
 	}
 	if !disableFormat {
-		if err := r.format(true, false, false, getFormatFixValue(fixFlag, meta), getFormatFileHeaderValue(fixFlag, meta), meta); err != nil {
+		if err := r.format(true, false, false, getFormatFixValue(fixFlag, meta), getFormatFileHeaderValue(fixFlag, meta), getFormatJavaPackagePrefixValue(fixFlag, meta), meta); err != nil {
 			return err
 		}
 	}
@@ -807,7 +807,7 @@ func (r *runner) newLintRunner() lint.Runner {
 	)
 }
 
-func (r *runner) newTransformer(fix int, fileHeader string) format.Transformer {
+func (r *runner) newTransformer(fix int, fileHeader string, javaPackagePrefix string) format.Transformer {
 	transformerOptions := []format.TransformerOption{format.TransformerWithLogger(r.logger)}
 	if fix != format.FixNone {
 		transformerOptions = append(transformerOptions, format.TransformerWithFix(fix))
@@ -815,6 +815,9 @@ func (r *runner) newTransformer(fix int, fileHeader string) format.Transformer {
 	if fileHeader != "" {
 		transformerOptions = append(transformerOptions, format.TransformerWithFileHeader(fileHeader))
 	}
+	if javaPackagePrefix != "" {
+		transformerOptions = append(transformerOptions, format.TransformerWithJavaPackagePrefix(javaPackagePrefix))
+	}
 	return format.NewTransformer(transformerOptions...)
 }
 
@@ -1037,6 +1040,13 @@ func getFormatFileHeaderValue(fixFlag bool, meta *meta) string {
 	return meta.ProtoSet.Config.Lint.FileHeader
 }
 
+func getFormatJavaPackagePrefixValue(fixFlag bool, meta *meta) string {
+	if !fixFlag {
+		return ""
+	}
+	return meta.ProtoSet.Config.Lint.JavaPackagePrefix
+}
+
 func extractSortPackageNames(m map[string]*extract.Package) []string {
 	s := make([]string, 0, len(m))
 	for key := range m {
diff --git a/internal/file/file.go b/internal/file/file.go
index ac42dbc..ea39e52 100644
--- a/internal/file/file.go
+++ b/internal/file/file.go
@@ -32,6 +32,8 @@ import (
 // DefaultWalkTimeout is the default walk timeout.
 const DefaultWalkTimeout time.Duration = 3 * time.Second
 
+var rootDirPath = filepath.Dir(string(filepath.Separator))
+
 // ProtoSet represents a set of .proto files and an associated config.
 //
 // ProtoSets will be validated if returned from this package.
@@ -151,3 +153,19 @@ func CheckAbs(path string) error {
 	}
 	return nil
 }
+
+// IsExcluded determines whether the given filePath should be excluded.
+//
+// absConfigDirPath represents the absolute path to the configuration file.
+// This is used to determine when we should stop checking for excludes.
+func IsExcluded(absFilePath string, absConfigDirPath string, absExcludePaths ...string) bool {
+	for _, absExcludePath := range absExcludePaths {
+		for curFilePath := absFilePath; curFilePath != absConfigDirPath && curFilePath != rootDirPath; curFilePath = filepath.Dir(curFilePath) {
+			if curFilePath == absExcludePath {
+				return true
+			}
+		}
+	}
+	return false
+
+}
diff --git a/internal/file/proto_set_provider.go b/internal/file/proto_set_provider.go
index 8206881..f8278c1 100644
--- a/internal/file/proto_set_provider.go
+++ b/internal/file/proto_set_provider.go
@@ -211,22 +211,18 @@ func (c *protoSetProvider) getConfig(absDirPath string) (settings.Config, error)
 // absWorkDirPath is only used to determine the ProtoFile.DisplayPath, also known as
 // the relative path from where prototool was invoked.
 func (c *protoSetProvider) walkAndGetAllProtoFiles(absWorkDirPath string, absDirPath string) ([]*ProtoFile, error) {
-	var (
-		protoFiles     []*ProtoFile
-		numWalkedFiles int
-		timedOut       bool
-	)
-	allExcludes := make(map[string]struct{})
+	var protoFiles []*ProtoFile
+	var numWalkedFiles int
+	var timedOut bool
+	var excludes []string
+	var err error
 	// if we have a configData, we compute the exclude prefixes once
 	// from this dirPath and data, and do not do it again in the below walk function
 	if c.configData != "" {
-		excludes, err := c.configProvider.GetExcludePrefixesForData(absWorkDirPath, c.configData)
+		excludes, err = c.configProvider.GetExcludePrefixesForData(absWorkDirPath, c.configData)
 		if err != nil {
 			return nil, err
 		}
-		for _, exclude := range excludes {
-			allExcludes[exclude] = struct{}{}
-		}
 	}
 	walkErrC := make(chan error)
 	go func() {
@@ -247,15 +243,13 @@ func (c *protoSetProvider) walkAndGetAllProtoFiles(absWorkDirPath string, absDir
 					// Add the excluded files with respect to the current file path.
 					// Do not add if we have configData.
 					if c.configData == "" {
-						excludes, err := c.configProvider.GetExcludePrefixesForDir(filePath)
+						iExcludes, err := c.configProvider.GetExcludePrefixesForDir(filePath)
 						if err != nil {
 							return err
 						}
-						for _, exclude := range excludes {
-							allExcludes[exclude] = struct{}{}
-						}
+						excludes = append(excludes, iExcludes...)
 					}
-					if isExcluded(filePath, absDirPath, allExcludes) {
+					if IsExcluded(filePath, absDirPath, excludes...) {
 						return filepath.SkipDir
 					}
 					return nil
@@ -263,7 +257,7 @@ func (c *protoSetProvider) walkAndGetAllProtoFiles(absWorkDirPath string, absDir
 				if filepath.Ext(filePath) != ".proto" {
 					return nil
 				}
-				if isExcluded(filePath, absDirPath, allExcludes) {
+				if IsExcluded(filePath, absDirPath, excludes...) {
 					return nil
 				}
 
@@ -339,32 +333,3 @@ func getDirPathToProtoFiles(protoFiles []*ProtoFile) map[string][]*ProtoFile {
 	}
 	return dirPathToProtoFiles
 }
-
-// isExcluded determines whether the given filePath should be excluded.
-// Note that all excludes are assumed to be cleaned absolute paths at
-// this point.
-// stopPath represents the absolute path to the prototool configuration.
-// This is used to determine when we should stop checking for excludes.
-func isExcluded(filePath, stopPath string, excludes map[string]struct{}) bool {
-	// Use the root as a fallback so that we don't loop forever.
-	root := filepath.Dir(string(filepath.Separator))
-
-	isNested := func(curr, exclude string) bool {
-		for {
-			if curr == stopPath || curr == root {
-				return false
-			}
-			if curr == exclude {
-				return true
-			}
-			curr = filepath.Dir(curr)
-		}
-	}
-	for exclude := range excludes {
-		if isNested(filePath, exclude) {
-			return true
-		}
-	}
-	return false
-
-}
diff --git a/internal/format/first_pass_visitor.go b/internal/format/first_pass_visitor.go
index 48a134e..915a4a0 100644
--- a/internal/format/first_pass_visitor.go
+++ b/internal/format/first_pass_visitor.go
@@ -43,6 +43,7 @@ type firstPassVisitor struct {
 	filename                 string
 	fix                      int
 	fileHeader               string
+	javaPackagePrefix        string
 	goPackageOption          *proto.Option
 	javaMultipleFilesOption  *proto.Option
 	javaOuterClassnameOption *proto.Option
@@ -52,8 +53,8 @@ type firstPassVisitor struct {
 	phpNamespaceOption       *proto.Option
 }
 
-func newFirstPassVisitor(filename string, fix int, fileHeader string) *firstPassVisitor {
-	return &firstPassVisitor{baseVisitor: newBaseVisitor(), filename: filename, fix: fix, fileHeader: fileHeader}
+func newFirstPassVisitor(filename string, fix int, fileHeader string, javaPackagePrefix string) *firstPassVisitor {
+	return &firstPassVisitor{baseVisitor: newBaseVisitor(), filename: filename, fix: fix, fileHeader: fileHeader, javaPackagePrefix: javaPackagePrefix}
 }
 
 func (v *firstPassVisitor) Do() []*text.Failure {
@@ -119,7 +120,7 @@ func (v *firstPassVisitor) Do() []*text.Failure {
 			IsString: true,
 		}
 		v.javaPackageOption.Constant = proto.Literal{
-			Source:   protostrs.JavaPackage(v.Package.Name),
+			Source:   protostrs.JavaPackagePrefixOverride(v.Package.Name, v.javaPackagePrefix),
 			IsString: true,
 		}
 		if v.fix == FixV2 {
diff --git a/internal/format/format.go b/internal/format/format.go
index 973ef86..8d95453 100644
--- a/internal/format/format.go
+++ b/internal/format/format.go
@@ -74,6 +74,16 @@ func TransformerWithFileHeader(fileHeader string) TransformerOption {
 	}
 }
 
+// TransformerWithJavaPackagePrefix returns a TransformerOption that will override
+// the Java package prefix.
+//
+// This is only valid if fix is set to a value other than FixNone.
+func TransformerWithJavaPackagePrefix(javaPackagePrefix string) TransformerOption {
+	return func(transformer *transformer) {
+		transformer.javaPackagePrefix = javaPackagePrefix
+	}
+}
+
 // NewTransformer returns a new Transformer.
 func NewTransformer(options ...TransformerOption) Transformer {
 	return newTransformer(options...)
diff --git a/internal/format/transformer.go b/internal/format/transformer.go
index ed0d366..51cfcb4 100644
--- a/internal/format/transformer.go
+++ b/internal/format/transformer.go
@@ -31,9 +31,10 @@ import (
 )
 
 type transformer struct {
-	logger     *zap.Logger
-	fix        int
-	fileHeader string
+	logger            *zap.Logger
+	fix               int
+	fileHeader        string
+	javaPackagePrefix string
 }
 
 func newTransformer(options ...TransformerOption) *transformer {
@@ -57,7 +58,7 @@ func (t *transformer) Transform(filename string, data []byte) ([]byte, []*text.F
 	}
 	descriptor.Filename = filename
 
-	firstPassVisitor := newFirstPassVisitor(filename, t.fix, t.fileHeader)
+	firstPassVisitor := newFirstPassVisitor(filename, t.fix, t.fileHeader, t.javaPackagePrefix)
 	for _, element := range descriptor.Elements {
 		element.Accept(firstPassVisitor)
 	}
diff --git a/internal/lint/BUILD.bazel b/internal/lint/BUILD.bazel
index c212991..c50effe 100644
--- a/internal/lint/BUILD.bazel
+++ b/internal/lint/BUILD.bazel
@@ -33,6 +33,7 @@ go_library(
         "check_message_field_names_filepath.go",
         "check_message_field_names_lower_snake_case.go",
         "check_message_field_names_lowercase.go",
+        "check_message_field_names_no_descriptor.go",
         "check_message_fields_duration.go",
         "check_message_fields_no_json_name.go",
         "check_message_fields_not_floats.go",
diff --git a/internal/lint/check_file_options_equal.go b/internal/lint/check_file_options_equal.go
index c7efe6e..89eb549 100644
--- a/internal/lint/check_file_options_equal.go
+++ b/internal/lint/check_file_options_equal.go
@@ -76,6 +76,14 @@ var fileOptionsEqualJavaPackageComPrefixLinter = NewLinter(
 	}),
 )
 
+var fileOptionsEqualJavaPackagePrefixLinter = NewLinter(
+	"FILE_OPTIONS_EQUAL_JAVA_PACKAGE_PREFIX",
+	`Verifies that the file option "java_package" is equal to PREFIX.PACKAGE, with PREFIX defaulting to "com" and configurable in your configuration file.`,
+	newCheckFileOptionsEqual("java_package", func(descriptor *FileDescriptor, pkg *proto.Package) string {
+		return protostrs.JavaPackagePrefixOverride(pkg.Name, descriptor.ProtoSet.Config.Lint.JavaPackagePrefix)
+	}),
+)
+
 var fileOptionsEqualOBJCClassPrefixAbbrLinter = NewLinter(
 	"FILE_OPTIONS_EQUAL_OBJC_CLASS_PREFIX_ABBR",
 	`Verifies that the file option "objc_class_prefix" is the abbreviated version of the package.`,
diff --git a/internal/lint/check_message_field_names_no_descriptor.go b/internal/lint/check_message_field_names_no_descriptor.go
new file mode 100644
index 0000000..6f4939d
--- /dev/null
+++ b/internal/lint/check_message_field_names_no_descriptor.go
@@ -0,0 +1,74 @@
+// Copyright (c) 2019 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+package lint
+
+import (
+	"strings"
+
+	"github.com/emicklei/proto"
+	"github.com/uber/prototool/internal/text"
+)
+
+var messageFieldNamesNoDescriptorLinter = NewLinter(
+	"MESSAGE_FIELD_NAMES_NO_DESCRIPTOR",
+	`Verifies that all message field names are not "descriptor", which results in a collision in Java-generated code.`,
+	checkMessageFieldNamesNoDescriptor,
+)
+
+func checkMessageFieldNamesNoDescriptor(add func(*text.Failure), dirPath string, descriptors []*FileDescriptor) error {
+	return runVisitor(messageFieldNamesNoDescriptorVisitor{baseAddVisitor: newBaseAddVisitor(add)}, descriptors)
+}
+
+type messageFieldNamesNoDescriptorVisitor struct {
+	baseAddVisitor
+}
+
+func (v messageFieldNamesNoDescriptorVisitor) VisitMessage(message *proto.Message) {
+	for _, element := range message.Elements {
+		element.Accept(v)
+	}
+}
+
+func (v messageFieldNamesNoDescriptorVisitor) VisitOneof(oneof *proto.Oneof) {
+	for _, element := range oneof.Elements {
+		element.Accept(v)
+	}
+}
+
+func (v messageFieldNamesNoDescriptorVisitor) VisitNormalField(field *proto.NormalField) {
+	v.handleField(field.Field)
+}
+
+func (v messageFieldNamesNoDescriptorVisitor) VisitOneofField(field *proto.OneOfField) {
+	v.handleField(field.Field)
+}
+
+func (v messageFieldNamesNoDescriptorVisitor) VisitMapField(field *proto.MapField) {
+	v.handleField(field.Field)
+}
+
+func (v messageFieldNamesNoDescriptorVisitor) handleField(field *proto.Field) {
+	// Descriptor, _descriptor, descriptor_, __descriptor, descriptor__ are also invalid
+	// https://github.com/protocolbuffers/protobuf/issues/5742
+	if strings.Trim(strings.ToLower(field.Name), "_") == "descriptor" {
+		v.AddFailuref(field.Position, `Field name %q causes a collision in Java-generated code.`, field.Name)
+	}
+}
diff --git a/internal/lint/lint.go b/internal/lint/lint.go
index d400efd..bff0839 100644
--- a/internal/lint/lint.go
+++ b/internal/lint/lint.go
@@ -61,6 +61,7 @@ var (
 		fileOptionsEqualJavaMultipleFilesTrueLinter,
 		fileOptionsEqualJavaOuterClassnameProtoSuffixLinter,
 		fileOptionsEqualJavaPackageComPrefixLinter,
+		fileOptionsEqualJavaPackagePrefixLinter,
 		fileOptionsEqualOBJCClassPrefixAbbrLinter,
 		fileOptionsEqualPHPNamespaceCapitalizedLinter,
 		fileOptionsGoPackageNotLongFormLinter,
@@ -89,6 +90,7 @@ var (
 		messageFieldNamesFilepathLinter,
 		messageFieldNamesLowerSnakeCaseLinter,
 		messageFieldNamesLowercaseLinter,
+		messageFieldNamesNoDescriptorLinter,
 		messageNamesCamelCaseLinter,
 		messageNamesCapitalizedLinter,
 		messagesHaveCommentsLinter,
@@ -204,7 +206,7 @@ var (
 		fileOptionsEqualGoPackageV2SuffixLinter,
 		fileOptionsEqualJavaMultipleFilesTrueLinter,
 		fileOptionsEqualJavaOuterClassnameProtoSuffixLinter,
-		fileOptionsEqualJavaPackageComPrefixLinter,
+		fileOptionsEqualJavaPackagePrefixLinter,
 		fileOptionsEqualOBJCClassPrefixAbbrLinter,
 		fileOptionsEqualPHPNamespaceCapitalizedLinter,
 		fileOptionsGoPackageNotLongFormLinter,
@@ -228,6 +230,7 @@ var (
 		messageFieldNamesFilenameLinter,
 		messageFieldNamesFilepathLinter,
 		messageFieldNamesLowerSnakeCaseLinter,
+		messageFieldNamesNoDescriptorLinter,
 		messageFieldsDurationLinter,
 		messageFieldsNoJSONNameLinter,
 		messageFieldsNotFloatsLinter,
@@ -509,12 +512,7 @@ func shouldIgnore(linter Linter, descriptor *FileDescriptor, ignoreIDToFilePaths
 	if !ok {
 		return false, nil
 	}
-	for _, ignoreFilePath := range ignoreFilePaths {
-		if filePath == ignoreFilePath {
-			return true, nil
-		}
-	}
-	return false, nil
+	return file.IsExcluded(filePath, descriptor.ProtoSet.Config.DirPath, ignoreFilePaths...), nil
 }
 
 func checkLintID(lintID string) error {
diff --git a/internal/protostrs/protostrs.go b/internal/protostrs/protostrs.go
index a63ff7f..b43641c 100644
--- a/internal/protostrs/protostrs.go
+++ b/internal/protostrs/protostrs.go
@@ -107,6 +107,20 @@ func JavaPackage(packageName string) string {
 	return "com." + packageName
 }
 
+// JavaPackagePrefixOverride returns the value for the file option "java_package" given
+// a package name. This will be equal to "com." followed by the package.
+// If prefixOverride is set, this will be equal to prefixOveride.package.
+// If packageName is empty, this will return an empty string.
+func JavaPackagePrefixOverride(packageName string, prefixOverride string) string {
+	if packageName == "" {
+		return ""
+	}
+	if prefixOverride != "" {
+		return prefixOverride + "." + packageName
+	}
+	return "com." + packageName
+}
+
 // OBJCClassPrefix returns the value for the file option "objc_class_prefix"
 // given a package name. It takes the first letter of each package part
 // and capitalizes it, then concatenates these. If the length is 2, an "X"
diff --git a/internal/settings/config_provider.go b/internal/settings/config_provider.go
index 00ff93b..4124f6b 100644
--- a/internal/settings/config_provider.go
+++ b/internal/settings/config_provider.go
@@ -351,6 +351,7 @@ func externalConfigToConfig(develMode bool, e ExternalConfig, dirPath string) (C
 			NoDefault:           e.Lint.Rules.NoDefault,
 			IgnoreIDToFilePaths: ignoreIDToFilePaths,
 			FileHeader:          fileHeader,
+			JavaPackagePrefix:   e.Lint.JavaPackagePrefix,
 			AllowSuppression:    e.Lint.AllowSuppression,
 		},
 		Gen: GenConfig{
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
index e128fd6..d55073c 100644
--- a/internal/settings/settings.go
+++ b/internal/settings/settings.go
@@ -198,6 +198,11 @@ type LintConfig struct {
 	// header before the syntax declaration. Note that format --fix will delete
 	// anything before the syntax declaration if this is set.
 	FileHeader string
+	// JavaPackagePrefix is the prefix for java packages. This only has an
+	// effect if the linter FILE_OPTIONS_EQUAL_JAVA_PACKAGE_PREFIX is turned on.
+	// This also affects create and format --fix.
+	// The default behavior is to use "com".
+	JavaPackagePrefix string
 	// AllowSuppression says to honor @suppresswarnings annotations.
 	AllowSuppression bool
 }
@@ -291,17 +296,18 @@ type ExternalConfig struct {
 		Ignores []struct {
 			ID    string   `json:"id,omitempty" yaml:"id,omitempty"`
 			Files []string `json:"files,omitempty" yaml:"files,omitempty"`
-		}
+		} `json:"ignores,omitempty" yaml:"ignores,omitempty"`
 		Rules struct {
 			NoDefault bool     `json:"no_default,omitempty" yaml:"no_default,omitempty"`
 			Add       []string `json:"add" yaml:"add"`
 			Remove    []string `json:"remove" yaml:"remove"`
-		}
+		} `json:"rules,omitempty" yaml:"rules,omitempty"`
 		FileHeader struct {
 			Path        string `json:"path,omitempty" yaml:"path,omitempty"`
 			Content     string `json:"content,omitempty" yaml:"content,omitempty"`
 			IsCommented bool   `json:"is_commented,omitempty" yaml:"is_commented,omitempty"`
 		} `json:"file_header,omitempty" yaml:"file_header,omitempty"`
+		JavaPackagePrefix string `json:"java_package_prefix,omitempty" yaml:"java_package_prefix,omitempty"`
 		// devel-mode only
 		AllowSuppression bool `json:"allow_suppression,omitempty" yaml:"allow_suppression,omitempty"`
 	} `json:"lint,omitempty" yaml:"lint,omitempty"`


Test patch:
diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go
index 9968146..4033580 100644
--- a/internal/cmd/cmd_test.go
+++ b/internal/cmd/cmd_test.go
@@ -198,6 +198,12 @@ func TestLint(t *testing.T) {
 		8:1:FILE_OPTIONS_EQUAL_JAVA_PACKAGE_COM_PREFIX`,
 		"testdata/lint/fileoptions/file_options_incorrect.proto",
 	)
+	assertDoLintFile(
+		t,
+		false,
+		`9:1:FILE_OPTIONS_EQUAL_JAVA_PACKAGE_PREFIX`,
+		"testdata/lint/fileoptionsjava/file_options_incorrect.proto",
+	)
 	assertDoLintFiles(
 		t,
 		false,
@@ -492,6 +498,18 @@ func TestLint(t *testing.T) {
 		"testdata/lint/notempty/foo/v1/hello_api.proto",
 	)
 
+	assertDoLintFile(
+		t,
+		false,
+		`14:3:MESSAGE_FIELD_NAMES_NO_DESCRIPTOR
+		18:3:MESSAGE_FIELD_NAMES_NO_DESCRIPTOR
+		22:3:MESSAGE_FIELD_NAMES_NO_DESCRIPTOR
+		26:3:MESSAGE_FIELD_NAMES_NO_DESCRIPTOR
+		30:3:MESSAGE_FIELD_NAMES_NO_DESCRIPTOR
+		34:3:MESSAGE_FIELD_NAMES_NO_DESCRIPTOR`,
+		"testdata/lint/nodescriptor/foo/v1/foo.proto",
+	)
+
 	assertDoLintFile(
 		t,
 		false,
@@ -578,6 +596,19 @@ func TestLint(t *testing.T) {
 		"testdata/lint/floatsnosuppress/foo/v1/foo.proto",
 	)
 
+	assertDoLintFile(
+		t,
+		false,
+		`14:3:MESSAGE_FIELDS_NOT_FLOATS`,
+		"testdata/lint/ignoredir/foo/v1/foo.proto",
+	)
+	assertDoLintFile(
+		t,
+		true,
+		``,
+		"testdata/lint/ignoredir/bar/v1/bar.proto",
+	)
+
 	assertDoLintFile(
 		t,
 		false,
@@ -1058,6 +1089,25 @@ option java_multiple_files = true;
 option java_outer_classname = "BazProto";
 option java_package = "com.foo.v1";
 option objc_class_prefix = "FXX";
+option php_namespace = "Foo\\V1";`,
+	)
+	// in dir with prototool.yaml with override with java_package_prefix
+	assertDoCreateFile(
+		t,
+		true,
+		true,
+		"testdata/create/version2five/baz.proto",
+		"",
+		`syntax = "proto3";
+
+package foo.v1;
+
+option csharp_namespace = "Foo.V1";
+option go_package = "foov1";
+option java_multiple_files = true;
+option java_outer_classname = "BazProto";
+option java_package = "au.com.foo.v1";
+option objc_class_prefix = "FXX";
 option php_namespace = "Foo\\V1";`,
 	)
 }
diff --git a/internal/cmd/testdata/create/version2five/prototool.yaml b/internal/cmd/testdata/create/version2five/prototool.yaml
new file mode 100644
index 0000000..10f2d8a
--- /dev/null
+++ b/internal/cmd/testdata/create/version2five/prototool.yaml
@@ -0,0 +1,7 @@
+lint:
+  group: uber2
+  java_package_prefix: au.com
+create:
+  packages:
+    - directory: .
+      name: foo.v1
diff --git a/internal/cmd/testdata/format-fix-v2/foo.proto.golden b/internal/cmd/testdata/format-fix-v2/foo.proto.golden
index e609541..43da883 100644
--- a/internal/cmd/testdata/format-fix-v2/foo.proto.golden
+++ b/internal/cmd/testdata/format-fix-v2/foo.proto.golden
@@ -13,7 +13,7 @@ option go_package = "barv1"; // inline comment4
 option java_multiple_files = true; //inline comment11
 // comment5
 option java_outer_classname = "FooProto"; // inline comment5
-option java_package = "com.foo.bar.v1";
+option java_package = "au.com.foo.bar.v1";
 option objc_class_prefix = "FBX";
 option php_namespace = "Foo\\Bar\\V1";
 
diff --git a/internal/cmd/testdata/format-fix-v2/prototool.yaml b/internal/cmd/testdata/format-fix-v2/prototool.yaml
index 092dbf5..cade831 100644
--- a/internal/cmd/testdata/format-fix-v2/prototool.yaml
+++ b/internal/cmd/testdata/format-fix-v2/prototool.yaml
@@ -1,2 +1,3 @@
 lint:
   group: uber2
+  java_package_prefix: au.com
diff --git a/internal/cmd/testdata/lint/fileoptionsjava/file_options_incorrect.proto b/internal/cmd/testdata/lint/fileoptionsjava/file_options_incorrect.proto
new file mode 100644
index 0000000..b0f9762
--- /dev/null
+++ b/internal/cmd/testdata/lint/fileoptionsjava/file_options_incorrect.proto
@@ -0,0 +1,11 @@
+syntax = "proto3";
+
+package foo;
+
+option csharp_namespace = "Foo";
+option go_package = "foopb";
+option java_multiple_files = true;
+option java_outer_classname = "FileOptionsIncorrectProto";
+option java_package = "com.foo";
+option objc_class_prefix = "FXX";
+option php_namespace = "Foo";
diff --git a/internal/cmd/testdata/lint/fileoptionsjava/prototool.yaml b/internal/cmd/testdata/lint/fileoptionsjava/prototool.yaml
new file mode 100644
index 0000000..61ecf2a
--- /dev/null
+++ b/internal/cmd/testdata/lint/fileoptionsjava/prototool.yaml
@@ -0,0 +1,6 @@
+lint:
+  group: uber2
+  java_package_prefix: au.com
+  rules:
+    remove:
+      - PACKAGE_MAJOR_BETA_VERSIONED
diff --git a/internal/cmd/testdata/lint/ignoredir/bar/v1/bar.proto b/internal/cmd/testdata/lint/ignoredir/bar/v1/bar.proto
new file mode 100644
index 0000000..c4dc80b
--- /dev/null
+++ b/internal/cmd/testdata/lint/ignoredir/bar/v1/bar.proto
@@ -0,0 +1,15 @@
+syntax = "proto3";
+
+package bar.v1;
+
+option csharp_namespace = "Bar.V1";
+option go_package = "barv1";
+option java_multiple_files = true;
+option java_outer_classname = "BarProto";
+option java_package = "com.bar.v1";
+option objc_class_prefix = "BXX";
+option php_namespace = "Bar\\V1";
+
+message Bar {
+  float one = 1;
+}
diff --git a/internal/cmd/testdata/lint/ignoredir/foo/v1/foo.proto b/internal/cmd/testdata/lint/ignoredir/foo/v1/foo.proto
new file mode 100644
index 0000000..ee89be0
--- /dev/null
+++ b/internal/cmd/testdata/lint/ignoredir/foo/v1/foo.proto
@@ -0,0 +1,15 @@
+syntax = "proto3";
+
+package foo.v1;
+
+option csharp_namespace = "Foo.V1";
+option go_package = "foov1";
+option java_multiple_files = true;
+option java_outer_classname = "FooProto";
+option java_package = "com.foo.v1";
+option objc_class_prefix = "FXX";
+option php_namespace = "Foo\\V1";
+
+message One {
+  float one = 1;
+}
diff --git a/internal/cmd/testdata/lint/ignoredir/prototool.yaml b/internal/cmd/testdata/lint/ignoredir/prototool.yaml
new file mode 100644
index 0000000..397b51d
--- /dev/null
+++ b/internal/cmd/testdata/lint/ignoredir/prototool.yaml
@@ -0,0 +1,14 @@
+lint:
+  group: uber2
+  rules:
+    remove:
+      - ENUMS_HAVE_SENTENCE_COMMENTS
+      - MESSAGES_HAVE_SENTENCE_COMMENTS_EXCEPT_REQUEST_RESPONSE_TYPES
+      - RPCS_HAVE_SENTENCE_COMMENTS
+      - SERVICES_HAVE_SENTENCE_COMMENTS
+  ignores:
+    - id: MESSAGE_FIELDS_NOT_FLOATS
+      files:
+        - bar/v1
+        - foo/v11
+
diff --git a/internal/cmd/testdata/lint/nodescriptor/foo/v1/foo.proto b/internal/cmd/testdata/lint/nodescriptor/foo/v1/foo.proto
new file mode 100644
index 0000000..59e7f20
--- /dev/null
+++ b/internal/cmd/testdata/lint/nodescriptor/foo/v1/foo.proto
@@ -0,0 +1,35 @@
+syntax = "proto3";
+
+package foo.v1;
+
+option csharp_namespace = "Foo.V1";
+option go_package = "foov1";
+option java_multiple_files = true;
+option java_outer_classname = "FooProto";
+option java_package = "com.foo.v1";
+option objc_class_prefix = "FXX";
+option php_namespace = "Foo\\V1";
+
+message One {
+  int64 descriptor = 1;
+}
+
+message Two {
+  int64 Descriptor = 1;
+}
+
+message Three {
+  int64 _descriptor = 1;
+}
+
+message Four {
+  int64 descriptor_ = 1;
+}
+
+message Five {
+  int64 __descriptor = 1;
+}
+
+message Six {
+  int64 descriptor__ = 1;
+}
diff --git a/internal/cmd/testdata/lint/nodescriptor/prototool.yaml b/internal/cmd/testdata/lint/nodescriptor/prototool.yaml
new file mode 100644
index 0000000..d86492d
--- /dev/null
+++ b/internal/cmd/testdata/lint/nodescriptor/prototool.yaml
@@ -0,0 +1,6 @@
+lint:
+  group: uber2
+  rules:
+    remove:
+      - MESSAGES_HAVE_SENTENCE_COMMENTS_EXCEPT_REQUEST_RESPONSE_TYPES
+      - MESSAGE_FIELD_NAMES_LOWER_SNAKE_CASE
diff --git a/internal/file/file_test.go b/internal/file/file_test.go
index 82bc6bf..76eff45 100644
--- a/internal/file/file_test.go
+++ b/internal/file/file_test.go
@@ -84,3 +84,20 @@ func TestCheckAbs(t *testing.T) {
 		assert.EqualError(t, CheckAbs("path/to/foo"), "expected absolute path but was path/to/foo")
 	})
 }
+
+func TestIsExcluded(t *testing.T) {
+	wd, err := os.Getwd()
+	require.NoError(t, err)
+	testIsExcluded(t, true, "/foo/bar", "/", "/foo")
+	testIsExcluded(t, true, "/foo", "/", "/foo")
+	testIsExcluded(t, false, filepath.Join(wd, "foo"), wd, filepath.Join(wd, "fooo"))
+	testIsExcluded(t, true, filepath.Join(wd, "foo/bar"), wd, filepath.Join(wd, "foo"))
+	testIsExcluded(t, false, filepath.Join(wd, "foo/bar"), wd, filepath.Join(wd, "bar"))
+	testIsExcluded(t, true, filepath.Join(wd, "foo.proto"), wd, filepath.Join(wd, "foo.proto"))
+	testIsExcluded(t, false, filepath.Join(wd, "bar"), wd, filepath.Join(wd, "bar/foo.proto"))
+	testIsExcluded(t, true, filepath.Join(wd, "bar/baz/foo.proto"), wd, filepath.Join(wd, "bar"))
+}
+
+func testIsExcluded(t *testing.T, expected bool, absFilePath string, absConfigDirPath string, absExcludePaths ...string) {
+	assert.Equal(t, expected, IsExcluded(absFilePath, absConfigDirPath, absExcludePaths...))
+}
diff --git a/internal/file/proto_set_provider_test.go b/internal/file/proto_set_provider_test.go
index 6cb40d3..ca4e9fc 100644
--- a/internal/file/proto_set_provider_test.go
+++ b/internal/file/proto_set_provider_test.go
@@ -442,57 +442,6 @@ func TestProtoSetProviderGetForDirConfigData(t *testing.T) {
 	)
 }
 
-func TestIsExcluded(t *testing.T) {
-	cwd, err := os.Getwd()
-	require.NoError(t, err)
-
-	tests := []struct {
-		desc     string
-		filePath string
-		stopPath string
-		excludes map[string]struct{}
-		excluded bool
-	}{
-		{
-			desc:     "Nothing excluded",
-			filePath: cwd,
-			excluded: false,
-		},
-		{
-			desc:     "String prefix of excluded dir is not excluded",
-			filePath: filepath.Join(cwd, "foo"),
-			stopPath: cwd,
-			excludes: map[string]struct{}{filepath.Join(cwd, "fooo"): struct{}{}},
-			excluded: false,
-		},
-		{
-			desc:     "Not excluded, terminates without stopPath",
-			filePath: filepath.Join(cwd, "foo"),
-			excludes: map[string]struct{}{filepath.Join(cwd, "bar"): struct{}{}},
-			excluded: false,
-		},
-		{
-			desc:     "Directory is exluded",
-			filePath: filepath.Join(cwd, "foo/bar"),
-			stopPath: cwd,
-			excludes: map[string]struct{}{filepath.Join(cwd, "foo"): struct{}{}},
-			excluded: true,
-		},
-		{
-			desc:     "File is exluded",
-			filePath: filepath.Join(cwd, "foo.proto"),
-			stopPath: cwd,
-			excludes: map[string]struct{}{filepath.Join(cwd, "foo.proto"): struct{}{}},
-			excluded: true,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.desc, func(t *testing.T) {
-			assert.Equal(t, tt.excluded, isExcluded(tt.filePath, tt.stopPath, tt.excludes))
-		})
-	}
-}
-
 func newTestProtoSetProvider(t *testing.T) *protoSetProvider {
 	return newProtoSetProvider(ProtoSetProviderWithLogger(newTestLogger(t)))
 }
diff --git a/internal/protostrs/protostrs_test.go b/internal/protostrs/protostrs_test.go
index 0987f9d..3679f1c 100644
--- a/internal/protostrs/protostrs_test.go
+++ b/internal/protostrs/protostrs_test.go
@@ -83,6 +83,14 @@ func TestJavaPackage(t *testing.T) {
 	assert.Equal(t, "com.foo.bar", JavaPackage("foo.bar"))
 }
 
+func TestJavaPackagePrefixOverride(t *testing.T) {
+	assert.Equal(t, "", JavaPackagePrefixOverride("", ""))
+	assert.Equal(t, "com.foo", JavaPackagePrefixOverride("foo", ""))
+	assert.Equal(t, "com.foo.bar", JavaPackagePrefixOverride("foo.bar", ""))
+	assert.Equal(t, "au.com.foo", JavaPackagePrefixOverride("foo", "au.com"))
+	assert.Equal(t, "au.com.foo.bar", JavaPackagePrefixOverride("foo.bar", "au.com"))
+}
+
 func TestOBJCClassPrefix(t *testing.T) {
 	assert.Equal(t, "", OBJCClassPrefix(""))
 	assert.Equal(t, "FXX", OBJCClassPrefix("foo"))