Skip to content

Commit

Permalink
feat(AIP-192): Catch unpaired backticks in comments
Browse files Browse the repository at this point in the history
  • Loading branch information
acamadeo committed Nov 29, 2023
1 parent ebf2763 commit 4ac2f2f
Show file tree
Hide file tree
Showing 4 changed files with 345 additions and 0 deletions.
68 changes: 68 additions & 0 deletions docs/rules/0192/closed-backticks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
rule:
aip: 192
name: [core, '0192', closed-backticks]
summary: Inline code should be surrounded by backticks.
permalink: /192/closed-backticks
redirect_from:
- /0192/closed-backticks
---

# Closed backticks

This rule enforces that any inline code in public comments for proto descriptors
is surrounded by backticks, as mandated in [AIP-192][].

## Details

This rule looks at each descriptor in each proto file (exempting oneofs and the
file itself) and complains if _public_ comments include text that only has
either an opening backtick or a closing backtick.

## Examples

**Incorrect** code for this rule:

```proto
// Incorrect.
// Fields on the book include:
//
// - name`: `string
message Book {
// The resource name of the book.
string name = 1;
}
```

**Correct** code for this rule:

```proto
// Correct.
// Fields on the book include:
//
// - `name`: `string`
message Book {
// The resource name of the book.
string name = 1;
}
```

## Disabling

If you need to violate this rule, use a leading comment above the descriptor.
Remember to also include an [aip.dev/not-precedent][] comment explaining why.

```proto
// Fields on the book include:
//
// - name`: `string
// (-- api-linter: core::0192::closed-backticks=disabled
// aip.dev/not-precedent: We need to do this because reasons. --)
message Book {
// The resource name of the book.
string name = 1;
}
```

[aip-192]: https://aip.dev/192
[aip.dev/not-precedent]: https://aip.dev/not-precedent
1 change: 1 addition & 0 deletions rules/aip0192/aip0192.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func AddRules(r lint.RuleRegistry) error {
return r.Register(
192,
absoluteLinks,
closedBackticks,
deprecatedComment,
hasComments,
noHTML,
Expand Down
186 changes: 186 additions & 0 deletions rules/aip0192/closed_backticks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package aip0192

import (
"regexp"
"strings"

"bitbucket.org/creachadair/stringset"
"github.com/googleapis/api-linter/lint"
"github.com/googleapis/api-linter/rules/internal/utils"
"github.com/jhump/protoreflect/desc"
)

type backtickPair int

const (
unspecified backtickPair = iota
missingOpening
missingClosing
paired
)

// Represents a backtick character in a line.
//
// To narrow which backticks to lint and the likely intended use of a stray
// backtick, we compute whether the backtick can be used as an opening and/or
// closing backtick.
//
// If the character before a backtick is one of the `separators`, the backtick
// is considered opening. Likewise, if the character after a backtick is one of
// the `separators`, the backtick is considered closing.
type backtick struct {
index int
opening bool
closing bool
pair backtickPair
}

var separators = stringset.New(" ", ":", ",", ".", ";", "-")
var reBacktick = regexp.MustCompile("`")

var closedBackticks = &lint.DescriptorRule{
Name: lint.NewRuleName(192, "closed-backticks"),
LintDescriptor: func(d desc.Descriptor) []lint.Problem {
problems := []lint.Problem{}

for _, comment := range utils.SeparateInternalComments(d.GetSourceInfo().GetLeadingComments()).External {
for _, line := range strings.Split(comment, "\n") {
// Pad the line with whitespace, so it's easier to lint
// backticks at the start and end of the line.
line = " " + line + " "

// Find all the backticks in the line and compute whether each
// one can be used as an opening and/or closing backtick.
backticks := []backtick{}
for _, loc := range reBacktick.FindAllStringIndex(line, -1) {
backticks = append(backticks, backtickAtIndex(line, loc[0]))
}

// Compute whether the backticks are paired.
backticks = computeBacktickPairs(filterUnusableBackticks(backticks))

// Add a problem for each backtick that is missing a pair.
for _, backtick := range backticks {
if backtick.pair != paired {
problems = append(problems, lint.Problem{
Message: "Inline code should be surrounded by backticks.",
Suggestion: "`" + suggestionWord(line, backtick) + "`",
Descriptor: d,
})
}
}
}
}
return problems
},
}

func backtickAtIndex(line string, index int) backtick {
return backtick{
index: index,
opening: separators.Contains(string(line[index-1])),
closing: separators.Contains(string(line[index+1])),
}
}

// Filter out backticks that cannot be used as opening or closing backticks.
func filterUnusableBackticks(backticks []backtick) []backtick {
list := []backtick{}
for _, backtick := range backticks {
if backtick.opening || backtick.closing {
list = append(list, backtick)
}
}
return list
}

// Compute whether each backtick is paired with an adjacent backtick.
//
// A backtick pair consists of a set of adjacent backticks, where the first is
// opening and the second is closing. Each backtick can be in at most one pair.
//
// This fills in the `pair` field for each backtick. It assumes every backtick
// is either opening, closing, or both.
func computeBacktickPairs(backticks []backtick) []backtick {
computed := []backtick{}

prevBacktickOpen := false
for i, backtick := range backticks {
backtick.pair = computePairState(backtick, prevBacktickOpen)

// If this backtick got paired, mark the previous one as paired as well.
if backtick.pair == paired {
computed[i-1].pair = paired
}

// If paired, this backtick cannot be used as an `opening` for another
// backtick.
prevBacktickOpen = false
if backtick.opening && backtick.pair != paired {
prevBacktickOpen = true
}

computed = append(computed, backtick)
}
return computed
}

func computePairState(backtick backtick, prevBacktickOpen bool) backtickPair {
// If the backtick is only opening, it needs a closing backtick.
if backtick.opening && !backtick.closing {
return missingClosing
}
// Otherwise, the backtick is closing. It is paired if the last backtick was
// opening.
if prevBacktickOpen {
return paired
}
return missingOpening
}

// Extract the word around an unpaired backtick.
//
// Even though inline code can include separators (e.g. colons, spaces), just
// suggest the string of characters up to the first separator character.
func suggestionWord(line string, backtick backtick) string {
switch backtick.pair {
case missingOpening:
return reverseString(firstWordBeforeSeparator(reverseString(line[:backtick.index])))
case missingClosing:
return firstWordBeforeSeparator(line[backtick.index+1:])
default:
return ""
}
}

func firstWordBeforeSeparator(s string) string {
index := strings.IndexFunc(s, func(r rune) bool {
return separators.Contains(string(r))
})
if index != -1 {
return s[:index]
}
return s
}

func reverseString(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
90 changes: 90 additions & 0 deletions rules/aip0192/closed_backticks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package aip0192

import (
"testing"

"github.com/googleapis/api-linter/rules/internal/testutils"
)

func TestClosedBackticks(t *testing.T) {
for _, test := range []struct {
name string
Comment string
problems testutils.Problems
}{
{"ValidWordChars", "`foo_bar_baz_1`", nil},
{"MissingFrontBacktickWordChars", "foo_bar_baz_1`", testutils.Problems{{Suggestion: "`foo_bar_baz_1`"}}},
{"MissingBackBacktickWordChars", "`foo_bar_baz_1", testutils.Problems{{Suggestion: "`foo_bar_baz_1`"}}},

{"ValidBetweenWords", "foo `bar` baz", nil},
{"MissingFrontBacktickBetweenWords", "foo bar` baz", testutils.Problems{{Suggestion: "`bar`"}}},
{"MissingBackBacktickBetweenWords", "foo `bar baz", testutils.Problems{{Suggestion: "`bar`"}}},

{"ValidNonWordChars", "`foo:bar/baz->qux.quux[0]()`", nil},
{"MissingFrontBacktickNonWordChars", "foo:bar/baz->qux.quux[0]()`", testutils.Problems{{Suggestion: "`quux[0]()`"}}},
{"MissingBackBacktickNonWordChars", "`foo:bar/baz->qux.quux[0]()", testutils.Problems{{Suggestion: "`foo`"}}},

{"ValidContainsSpace", "`foo + bar`", nil},
{"MissingFrontBacktickContainsSpace", "foo + bar`", testutils.Problems{{Suggestion: "`bar`"}}},
{"MissingBackBacktickContainsSpace", "`foo + bar", testutils.Problems{{Suggestion: "`foo`"}}},

{"ValidNonAscii", "`汉语`", nil},
{"MissingFrontBacktickNonAscii", "汉语`", testutils.Problems{{Suggestion: "`汉语`"}}},
{"MissingBackBacktickNonAscii", "`汉语", testutils.Problems{{Suggestion: "`汉语`"}}},

{"ValidQuotes", "`\"foo\"`", nil},
{"MissingFrontBacktickQuotes", "\"foo\"`", testutils.Problems{{Suggestion: "`\"foo\"`"}}},
{"MissingBackBacktickQuotes", "`\"foo\"", testutils.Problems{{Suggestion: "`\"foo\"`"}}},

{"ValidSeparatedByColons", "foo:`bar:baz`:qux", nil},
{"MissingFrontBacktickSeparatedByColons", "foo:`bar:baz:qux", testutils.Problems{{Suggestion: "`bar`"}}},
{"MissingBackBacktickSeparatedByColons", "foo:bar:baz`:qux", testutils.Problems{{Suggestion: "`baz`"}}},

{"ValidComma", "`name`, a string", nil},
{"MissingFrontBacktickComma", "name`, a string", testutils.Problems{{Suggestion: "`name`"}}},
{"MissingBackBacktickComma", "`name, a string", testutils.Problems{{Suggestion: "`name`"}}},

{"ValidMultipleCodeText", "`name`: `string`", nil},
{"MissingFrontBacktickMultipleCodeText", "name`: string`", testutils.Problems{{Suggestion: "`name`"}, {Suggestion: "`string`"}}},
{"MissingBackBacktickMultipleCodeText", "`name: `string", testutils.Problems{{Suggestion: "`name`"}, {Suggestion: "`string`"}}},
{"MissingFrontAndBackBacktickMultipleCodeText", "name`: `string", testutils.Problems{{Suggestion: "`name`"}, {Suggestion: "`string`"}}},

{"ValidContainsColon", "`name: string`", nil},

{"ValidContainsSeparator", "`.`", nil},
{"MissingFrontBacktickContainsSeparator", ".`", testutils.Problems{{Suggestion: "``"}}},
{"MissingBackBacktickContainsSeparator", "`.", testutils.Problems{{Suggestion: "``"}}},

{"ValidEmptySpace", "``", nil},
{"ValidEmptySpaces", "`` ``", nil},
{"UnpairedEmptySpace", "`` `", testutils.Problems{{Suggestion: "``"}}},

{"IgnoreTripleBacktick", "```", nil},
{"IgnoreBackticksWithinWords", "a`b`c`d", nil},
} {
t.Run(test.name, func(t *testing.T) {
f := testutils.ParseProto3Tmpl(t, `
// {{.Comment}}
message Foo {}
`, test)
m := f.GetMessageTypes()[0]
if diff := test.problems.SetDescriptor(m).Diff(closedBackticks.Lint(f)); diff != "" {
t.Errorf(diff)
}
})
}
}

0 comments on commit 4ac2f2f

Please sign in to comment.