diff --git a/internal/core/format.go b/internal/core/format.go index 36593771..0d499dc8 100755 --- a/internal/core/format.go +++ b/internal/core/format.go @@ -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"}, diff --git a/internal/lint/code/comments_test.go b/internal/lint/code/comments_test.go index 1a075391..98da593c 100644 --- a/internal/lint/code/comments_test.go +++ b/internal/lint/code/comments_test.go @@ -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) + } +} diff --git a/internal/lint/code/ex.go b/internal/lint/code/ex.go new file mode 100644 index 00000000..0170de8d --- /dev/null +++ b/internal/lint/code/ex.go @@ -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{"#"}) + }, + } +} diff --git a/internal/lint/code/lang.go b/internal/lint/code/lang.go index 7ed28f4b..4686e955 100644 --- a/internal/lint/code/lang.go +++ b/internal/lint/code/lang.go @@ -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": diff --git a/internal/lint/code/query.go b/internal/lint/code/query.go index 4fc9df4e..b630678e 100644 --- a/internal/lint/code/query.go +++ b/internal/lint/code/query.go @@ -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) diff --git a/testdata/comments/in/8.ex b/testdata/comments/in/8.ex new file mode 100644 index 00000000..84f8cdc9 --- /dev/null +++ b/testdata/comments/in/8.ex @@ -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 diff --git a/testdata/comments/in/9.exs b/testdata/comments/in/9.exs new file mode 100644 index 00000000..f2824d89 --- /dev/null +++ b/testdata/comments/in/9.exs @@ -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 diff --git a/testdata/comments/out/8.json b/testdata/comments/out/8.json new file mode 100644 index 00000000..2bfdda05 --- /dev/null +++ b/testdata/comments/out/8.json @@ -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" + } +] \ No newline at end of file diff --git a/testdata/comments/out/9.json b/testdata/comments/out/9.json new file mode 100644 index 00000000..80089fa7 --- /dev/null +++ b/testdata/comments/out/9.json @@ -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" + } +] \ No newline at end of file diff --git a/testdata/e2e/lint.yaml b/testdata/e2e/lint.yaml index aaef90cc..b80b896c 100644 --- a/testdata/e2e/lint.yaml +++ b/testdata/e2e/lint.yaml @@ -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 diff --git a/testdata/fixtures/formats/test.ex b/testdata/fixtures/formats/test.ex new file mode 100644 index 00000000..84f8cdc9 --- /dev/null +++ b/testdata/fixtures/formats/test.ex @@ -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