Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/core/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ var FormatByExtension = map[string][]string{
`\.(?:css)$`: {".css", "code"},
`\.(?:cs|csx)$`: {".c", "code"},
`\.(?:dita)$`: {".dita", "markup"},
`\.(?:ex|exs)$`: {".ex", "code"},
`\.(?:go)$`: {".go", "code"},
`\.(?:hs)$`: {".hs", "code"},
`\.(?:html|htm|shtml|xhtml)$`: {".html", "markup"},
Expand Down
47 changes: 47 additions & 0 deletions internal/lint/code/comments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,50 @@ func TestComments(t *testing.T) {
}
}
}

// TestPredicateOnlyCapture covers the underscore convention: a capture that
// exists for a predicate to test is not itself linted.
//
// Without it, testing one node and extracting another is impossible -- the
// only testable node is the one you extract -- which is what the Elixir doc
// attributes need (the attribute name decides, the heredoc is the prose).
func TestPredicateOnlyCapture(t *testing.T) {
source := []byte("defmodule M do\n @moduledoc \"Prose.\"\nend\n")

lang, err := GetLanguageFromExt(".ex")
if err != nil {
t.Fatal(err)
}

comments, err := GetComments(source, lang)
if err != nil {
t.Fatal(err)
}

if len(comments) != 1 {
t.Fatalf("got %d comments, want 1: %v", len(comments), comments)
} else if comments[0].Text != "Prose." {
t.Errorf("got %q, want %q", comments[0].Text, "Prose.")
}
}

// TestDocAttributesWithoutProse covers the attributes that hold no prose:
// `@doc false` hides a function and `@doc since:` is metadata. Neither has a
// string argument, so neither is extracted.
func TestDocAttributesWithoutProse(t *testing.T) {
source := []byte("defmodule M do\n @doc false\n @doc since: \"1.0.0\"\n def f, do: :ok\nend\n")

lang, err := GetLanguageFromExt(".ex")
if err != nil {
t.Fatal(err)
}

comments, err := GetComments(source, lang)
if err != nil {
t.Fatal(err)
}

if len(comments) != 0 {
t.Errorf("got %d comments, want 0: %v", len(comments), comments)
}
}
54 changes: 54 additions & 0 deletions internal/lint/code/ex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package code

import (
"regexp"

"github.com/errata-ai/vale/v3/internal/core"
"github.com/smacker/go-tree-sitter/elixir"
)

// Elixir extracts `#` comments and the prose held by the `@moduledoc`,
// `@doc`, `@typedoc` and `@shortdoc` attributes.
//
// The attributes are the point. Elixir has no documentation comment syntax:
// its API documentation lives in module attributes holding a string or a
// heredoc, and that is what `mix docs` publishes and what a reader of the
// module reads first. A comment-only pass sees the asides and none of the
// documentation.
//
// They are given a `doc` meta scope -- `text.comment.doc.line` and
// `text.comment.doc.block` -- so that published documentation can be held to
// a different standard than an implementation note, or excluded on its own.
//
// The queries capture `quoted_content`, the body of the string, rather than
// the string itself. That leaves the delimiters out of the extracted text
// without a `Delims` pattern having to take them off, which matters for the
// single-quoted form: stripping its `"` with a regex would also strip any
// quote written inside the prose. `@doc false` and `@doc since: "1.0"` carry
// no prose, and neither matches a query that requires a string or sigil.
func Elixir() *Language {
return &Language{
Delims: regexp.MustCompile(`#`),
Parser: elixir.GetLanguage(),
Queries: []core.Scope{
{Name: "", Expr: `(comment) @comment`, Type: ""},
// `@` applied to a call whose target names a documentation
// attribute and whose argument is a string or sigil.
//
// The attribute name is tested through `@_attr`, a
// predicate-only capture, so that the prose can be captured on
// its own.
{Name: "doc", Expr: `((unary_operator
operand: (call
target: (identifier) @_attr
(arguments [
(string (quoted_content) @comment)
(sigil (quoted_content) @comment)
])))
(#match? @_attr "^(module|type|short)?doc$"))`, Type: ""},
},
Padding: func(s string) int {
return computePadding(s, []string{"#"})
},
}
}
2 changes: 2 additions & 0 deletions internal/lint/code/lang.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ func GetLanguageFromExt(ext string) (*Language, error) {
return Python(), nil
case ".rb":
return Ruby(), nil
case ".ex":
return Elixir(), nil
case ".cpp":
return Cpp(), nil
case ".c":
Expand Down
10 changes: 10 additions & 0 deletions internal/lint/code/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ func (qe *QueryEngine) run(meta string, q *sitter.Query, source []byte) []Commen

m = qc.FilterPredicates(m, source)
for _, c := range m.Captures {
// A capture named with a leading underscore exists for a
// predicate to test, not to be linted -- the convention
// tree-sitter itself uses for internal captures. Without this,
// the only way to test one node and extract another is to test
// the node you extract, which forces a query to capture more
// than the prose it wants.
if strings.HasPrefix(q.CaptureNameForId(uint32(c.Index)), "_") {
continue
}

rText := c.Node.Content(source)
row := int(c.Node.StartPoint().Row)
offset := int(c.Node.StartPoint().Column)
Expand Down
29 changes: 29 additions & 0 deletions testdata/comments/in/8.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
defmodule Test do
@moduledoc """
NOTE: a heredoc doc attribute.

iex> Test.run()
:ok

FIXME: indented content keeps its indentation.
"""

# NOTE: a line comment
# XXX: continued on the next line

@doc "TODO: a single-quoted doc attribute."
def run, do: :ok

@doc false
def hidden, do: :ok

@typedoc ~S"""
XXX: a sigil doc attribute.
"""
@type t :: term()

def code do
_ = "TODO: a string, not a comment"
:ok # FIXME: a trailing comment
end
end
8 changes: 8 additions & 0 deletions testdata/comments/in/9.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# A script's leading comment.
Mix.install([:jason])

defmodule Script do
@moduledoc "One line of documentation."
@doc since: "1.0.0"
def run, do: :ok
end
37 changes: 37 additions & 0 deletions testdata/comments/out/8.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[
{
"Text": "NOTE: a heredoc doc attribute.\n\n iex\u003e Test.run()\n :ok\n\nFIXME: indented content keeps its indentation.\n\n",
"Source": "NOTE: a heredoc doc attribute.\n\n iex\u003e Test.run()\n :ok\n\n FIXME: indented content keeps its indentation.\n ",
"Line": 3,
"Offset": 2,
"Scope": "text.comment.doc.block"
},
{
"Text": "NOTE: a line comment\nXXX: continued on the next line\n",
"Source": "# NOTE: a line comment\n# XXX: continued on the next line\n",
"Line": 11,
"Offset": 2,
"Scope": "text.comment.line"
},
{
"Text": "TODO: a single-quoted doc attribute.",
"Source": "TODO: a single-quoted doc attribute.",
"Line": 14,
"Offset": 8,
"Scope": "text.comment.doc.line"
},
{
"Text": "XXX: a sigil doc attribute.\n \n",
"Source": "XXX: a sigil doc attribute.\n ",
"Line": 21,
"Offset": 2,
"Scope": "text.comment.doc.block"
},
{
"Text": "FIXME: a trailing comment",
"Source": "# FIXME: a trailing comment",
"Line": 27,
"Offset": 9,
"Scope": "text.comment.line"
}
]
16 changes: 16 additions & 0 deletions testdata/comments/out/9.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[
{
"Text": "A script's leading comment.",
"Source": "# A script's leading comment.",
"Line": 1,
"Offset": 0,
"Scope": "text.comment.line"
},
{
"Text": "One line of documentation.",
"Source": "One line of documentation.",
"Line": 5,
"Offset": 14,
"Scope": "text.comment.doc.line"
}
]
12 changes: 12 additions & 0 deletions testdata/e2e/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,18 @@ cases:
test.lua:9:6:vale.Annotations:'XXX' left in text
test.lua:15:4:vale.Annotations:'TODO' left in text

- name: elixir
args: test.ex
exit: 0
want: |
test.ex:3:3:vale.Annotations:'NOTE' left in text
test.ex:8:3:vale.Annotations:'FIXME' left in text
test.ex:11:5:vale.Annotations:'NOTE' left in text
test.ex:12:5:vale.Annotations:'XXX' left in text
test.ex:14:9:vale.Annotations:'TODO' left in text
test.ex:21:3:vale.Annotations:'XXX' left in text
test.ex:27:12:vale.Annotations:'FIXME' left in text

- name: haskell
args: test.hs
exit: 0
Expand Down
29 changes: 29 additions & 0 deletions testdata/fixtures/formats/test.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
defmodule Test do
@moduledoc """
NOTE: a heredoc doc attribute.

iex> Test.run()
:ok

FIXME: indented content keeps its indentation.
"""

# NOTE: a line comment
# XXX: continued on the next line

@doc "TODO: a single-quoted doc attribute."
def run, do: :ok

@doc false
def hidden, do: :ok

@typedoc ~S"""
XXX: a sigil doc attribute.
"""
@type t :: term()

def code do
_ = "TODO: a string, not a comment"
:ok # FIXME: a trailing comment
end
end