Repository: preslavmihaylov/todocheck
Pull request #212 (2025-02-18 09:23:20)

Add support for twig templates


Patch:
diff --git a/README.md b/README.md
index 1ed72ea..a13fc0a 100644
--- a/README.md
+++ b/README.md
@@ -296,27 +296,28 @@ Currently, todocheck has parsers for three different types of comments:
 
 Based on this, here are the supported languages:
 
-| Supported Languages   | Description                                                                                                            |
-|-----------------------|------------------------------------------------------------------------------------------------------------------------|
-| Bash/Zsh              | `*.sh/*.bash/*.zsh` extension. Supports `#` single-line comments                                                       |
-| C                     | `*.c` extension. Supports single-line `//` comments and multi-line `/* */` comments                                    |
-| C++                   | `*.cpp` extension. Supports single-line `//` comments and multi-line `/* */` comments                                  |
-| CSharp                | `*.cs` extension. Supports single-line `//` comments and multi-line `/* */` comments                                   |
-| Dart                  | `*.dart` extension. Supports single-line `//` comments and multi-line `/* */` comments
-| Golang                | `*.go` extension. Supports single-line `//` comments and multi-line `/* */` comments                                   |
-| Groovy                | `*.groovy`, `*.gvy`, `*.gy`, `*.gsh` extensions. Supports single-line `//` comments and multi-line `/* */` comments    |
-| Java                  | `*.java` extension. Supports single-line `//` comments and multi-line `/* */` comments                                 |
-| JavaScript/Typescript | `*.js/*.ts` extension. Supports single-line `//` comments and multi-line `/* */` comments                              |
-| Kotlin                | `*.kt/*.kts/*.ktm` extension. Supports single-line `//` comments and multi-line `/* */` comments                              |
-| Nim                   | `*.{nim, nims, nimble}` extension. Supports single-line `#` comments and multi-line `#[ ]#` comments                          |
-| PHP                   | `*.php` extension. Supports single-line `#` and `//` comments and multi-line `/* */` comments                          |
-| Python                | `*.py` extension. Supports single-line `#` comments and multi-line `"""` comments                                      |
-| R                     | `*.R` extension. Supports single-line `//` comments and multi-line `/* */` comments                                    |
-| Rust                  | `*.rs` extension. Supports single-line `//` comments and multi-line `/* */` comments                                   |
-| Scala                 | `*.scala`, `*.sc` extensions. Supports single-line `//` comments and multi-line `/* */` comments                                 |
-| Swift                 | `*.swift` extension. Supports single-line `//` comments and multi-line `/* */` comments
-| Vue                 | `*.vue` extension. Supports single-line `//` comments, multi-line `/* */` comments and multi-line `<!-- -->` HTML comments                                |
-| Yaml                  | `*.yaml`, `*.yml` extension. Supports `#` single-line comments                                                         |
+| Supported Languages   | Description                                                                                                                |
+|-----------------------|----------------------------------------------------------------------------------------------------------------------------|
+| Bash/Zsh              | `*.sh/*.bash/*.zsh` extension. Supports `#` single-line comments                                                           |
+| C                     | `*.c` extension. Supports single-line `//` comments and multi-line `/* */` comments                                        |
+| C++                   | `*.cpp` extension. Supports single-line `//` comments and multi-line `/* */` comments                                      |
+| CSharp                | `*.cs` extension. Supports single-line `//` comments and multi-line `/* */` comments                                       |
+| Dart                  | `*.dart` extension. Supports single-line `//` comments and multi-line `/* */` comments                                     |
+| Golang                | `*.go` extension. Supports single-line `//` comments and multi-line `/* */` comments                                       |
+| Groovy                | `*.groovy`, `*.gvy`, `*.gy`, `*.gsh` extensions. Supports single-line `//` comments and multi-line `/* */` comments        |
+| Java                  | `*.java` extension. Supports single-line `//` comments and multi-line `/* */` comments                                     |
+| JavaScript/Typescript | `*.js/*.ts` extension. Supports single-line `//` comments and multi-line `/* */` comments                                  |
+| Kotlin                | `*.kt/*.kts/*.ktm` extension. Supports single-line `//` comments and multi-line `/* */` comments                           |
+| Nim                   | `*.{nim, nims, nimble}` extension. Supports single-line `#` comments and multi-line `#[ ]#` comments                       |
+| PHP                   | `*.php` extension. Supports single-line `#` and `//` comments and multi-line `/* */` comments                              |
+| Python                | `*.py` extension. Supports single-line `#` comments and multi-line `"""` comments                                          |
+| R                     | `*.R` extension. Supports single-line `//` comments and multi-line `/* */` comments                                        |
+| Rust                  | `*.rs` extension. Supports single-line `//` comments and multi-line `/* */` comments                                       |
+| Scala                 | `*.scala`, `*.sc` extensions. Supports single-line `//` comments and multi-line `/* */` comments                           |
+| Swift                 | `*.swift` extension. Supports single-line `//` comments and multi-line `/* */` comments                                    |
+| Twig                  | `*.twig` extension. Supports multi-line `{# #}` and HTML `<!-- -->` comments                                               |
+| Vue                   | `*.vue` extension. Supports single-line `//` comments, multi-line `/* */` comments and multi-line `<!-- -->` HTML comments |
+| Yaml                  | `*.yaml`, `*.yml` extension. Supports `#` single-line comments                                                             |
 
 If you don't see your favorite language in this table, but it does use one of the supported comment formats, submit an issue [here](https://github.com/preslavmihaylov/todocheck/issues/new)
 
diff --git a/matchers/matchers.go b/matchers/matchers.go
index dc41ef3..32227ff 100644
--- a/matchers/matchers.go
+++ b/matchers/matchers.go
@@ -11,6 +11,7 @@ import (
 	"github.com/preslavmihaylov/todocheck/matchers/scripts"
 	"github.com/preslavmihaylov/todocheck/matchers/standard"
 	"github.com/preslavmihaylov/todocheck/matchers/state"
+	"github.com/preslavmihaylov/todocheck/matchers/twig"
 	"github.com/preslavmihaylov/todocheck/matchers/vue"
 )
 
@@ -158,6 +159,22 @@ var (
 			return nim.NewCommentMatcher(callback)
 		},
 	}
+	twigMatcherFactory = &matcherFactory{
+		func() func([]string) TodoMatcher {
+			var once sync.Once
+			var matcher TodoMatcher
+
+			return func(customTodos []string) TodoMatcher {
+				once.Do(func() {
+					matcher = twig.NewTodoMatcher(customTodos)
+				})
+				return matcher
+			}
+		}(),
+		func(callback state.CommentCallback) CommentMatcher {
+			return twig.NewCommentMatcher(callback)
+		},
+	}
 )
 
 var supportedMatchers = map[string]*matcherFactory{
@@ -207,6 +224,9 @@ var supportedMatchers = map[string]*matcherFactory{
 	".nim":    nimMatcherFactory,
 	".nims":   nimMatcherFactory,
 	".nimble": nimMatcherFactory,
+
+	// file types, supporting twig and html comments
+	".twig": twigMatcherFactory,
 }
 
 // TodoMatcherForFile gets the correct todo matcher for the given filename
diff --git a/matchers/twig/comments.go b/matchers/twig/comments.go
new file mode 100644
index 0000000..a404a23
--- /dev/null
+++ b/matchers/twig/comments.go
@@ -0,0 +1,136 @@
+package twig
+
+import (
+	"github.com/preslavmihaylov/todocheck/matchers/state"
+)
+
+// NewCommentMatcher for twig comments
+func NewCommentMatcher(callback state.CommentCallback) *CommentMatcher {
+	return &CommentMatcher{
+		callback: callback,
+	}
+}
+
+// CommentMatcher for twig comments
+type CommentMatcher struct {
+	callback                  state.CommentCallback
+	buffer                    string
+	lines                     []string
+	linecnt                   int
+	stringToken               rune
+	isExitingMultilineComment bool
+	commentType               string
+	isStartingHTML            bool
+}
+
+// NonCommentState for twig comments
+func (m *CommentMatcher) NonCommentState(
+	filename, line string, linecnt int, prevToken, currToken, nextToken rune,
+) (state.CommentState, error) {
+	if prevToken == '{' && currToken == '#' {
+		m.buffer += "{#"
+		m.lines = []string{line}
+		m.linecnt = linecnt
+		m.commentType = "Twig"
+
+		return state.MultiLineComment, nil
+	} else if prevToken == '<' && currToken == '!' && nextToken == '-' {
+		m.isStartingHTML = true
+
+		return state.NonComment, nil
+	} else if m.isStartingHTML && nextToken == '-' {
+		m.buffer += "<!-"
+		m.lines = []string{line}
+		m.linecnt = linecnt
+		m.commentType = "HTML"
+
+		return state.MultiLineComment, nil
+	} else if m.isStartingHTML && nextToken != '-' {
+		m.isStartingHTML = false
+	}
+
+	return state.NonComment, nil
+}
+
+// StringState for twig comments
+func (m *CommentMatcher) StringState(
+	filename, line string, linecnt int, prevToken, currToken, nextToken rune,
+) (state.CommentState, error) {
+	if prevToken != '\\' && currToken == m.stringToken {
+		return state.NonComment, nil
+	}
+
+	return state.String, nil
+}
+
+// SingleLineCommentState for twig comments
+func (m *CommentMatcher) SingleLineCommentState(
+	filename, line string, linecnt int, prevToken, currToken, nextToken rune,
+) (state.CommentState, error) {
+	if currToken == '\n' {
+		err := m.callback(m.buffer, filename, []string{line}, linecnt)
+		if err != nil {
+			return state.NonComment, err
+		}
+
+		m.resetState()
+		return state.NonComment, nil
+	}
+
+	m.buffer += string(currToken)
+	return state.SingleLineComment, nil
+}
+
+// MultiLineCommentState for twig comments
+func (m *CommentMatcher) MultiLineCommentState(
+	filename, line string, linecnt int, prevToken, currToken, nextToken rune,
+) (state.CommentState, error) {
+	m.buffer += string(currToken)
+
+	if m.isExitingMultilineComment {
+
+		err := m.callback(m.buffer, filename, m.lines, m.linecnt)
+		if err != nil {
+			return state.NonComment, err
+		}
+
+		m.resetState()
+
+		return state.NonComment, nil
+	}
+
+	if isEndOfMultilineComment(m.commentType, prevToken, currToken, nextToken) {
+		m.isExitingMultilineComment = true
+		return state.MultiLineComment, nil
+	}
+
+	if prevToken == '\n' {
+		m.lines = append(m.lines, line)
+	}
+
+	return state.MultiLineComment, nil
+}
+
+func (m *CommentMatcher) resetState() {
+	m.buffer = ""
+	m.lines = nil
+	m.linecnt = 0
+	m.stringToken = 0
+	m.commentType = ""
+	m.isStartingHTML = false
+	m.isExitingMultilineComment = false
+}
+
+func isEndOfMultilineComment(commentType string, prev, curr, next rune) bool {
+	if commentType == "Twig" {
+		if curr == '#' && next == '}' {
+			return true
+		}
+	}
+	if commentType == "HTML" {
+		if prev == '-' && curr == '-' && next == '>' {
+			return true
+		}
+	}
+	return false
+}
diff --git a/matchers/twig/doc.go b/matchers/twig/doc.go
new file mode 100644
index 0000000..94a6f1d
--- /dev/null
+++ b/matchers/twig/doc.go
@@ -0,0 +1,3 @@
+// Package php contains a todo matcher & comments matcher for PHP comments.
+// Twig comments use {# #} and HTML <!-- --> comments
+package twig
diff --git a/matchers/twig/todomatcher.go b/matchers/twig/todomatcher.go
new file mode 100644
index 0000000..0e9177a
--- /dev/null
+++ b/matchers/twig/todomatcher.go
@@ -0,0 +1,52 @@
+package twig
+
+import (
+	"regexp"
+
+	"github.com/preslavmihaylov/todocheck/common"
+	"github.com/preslavmihaylov/todocheck/matchers/errors"
+)
+
+// NewTodoMatcher for vue comments
+func NewTodoMatcher(todos []string) *TodoMatcher {
+	pattern := common.ArrayAsRegexAnyMatchExpression(todos)
+
+	multiLineTodoPattern := regexp.MustCompile(`(?s)^\s*(<\!--|{#).*` + pattern)
+	multiLineValidTodoPattern := regexp.MustCompile(`(?s)^\s*(<\!--|{#).*` + pattern + ` (#?[a-zA-Z0-9\-]+):.*`)
+
+	return &TodoMatcher{
+		multiLineTodoPattern:      multiLineTodoPattern,
+		multiLineValidTodoPattern: multiLineValidTodoPattern,
+	}
+}
+
+// TodoMatcher for vue comments
+type TodoMatcher struct {
+	multiLineTodoPattern      *regexp.Regexp
+	multiLineValidTodoPattern *regexp.Regexp
+}
+
+// IsMatch checks if the current expression matches a vue comment
+func (m *TodoMatcher) IsMatch(expr string) bool {
+	return m.multiLineTodoPattern.Match([]byte(expr))
+}
+
+// IsValid checks if the expression is a valid todo comment
+func (m *TodoMatcher) IsValid(expr string) bool {
+	return m.multiLineValidTodoPattern.Match([]byte(expr))
+}
+
+// ExtractIssueRef from the given expression.
+// If the expression is invalid, an ErrInvalidTODO is returned
+func (m *TodoMatcher) ExtractIssueRef(expr string) (string, error) {
+	if !m.IsValid(expr) {
+		return "", errors.ErrInvalidTODO
+	}
+
+	multiLineRes := m.multiLineValidTodoPattern.FindStringSubmatch(expr)
+	if len(multiLineRes) >= 3 {
+		return multiLineRes[2], nil
+	}
+
+	panic("Invariant violated. No issue reference found in valid TODO")
+}


Test patch:
diff --git a/testing/scenarios/twig/main.html.twig b/testing/scenarios/twig/main.html.twig
new file mode 100644
index 0000000..cdaf4d5
--- /dev/null
+++ b/testing/scenarios/twig/main.html.twig
@@ -0,0 +1,25 @@
+{# TODO: malformed todo #}
+{# TODO 1: valid todo #}
+{# TODO 2: The issue is closed #}
+{# TODO 3: The issue is non-existent #}
+
+{#
+    TODO: malformed todo
+#}
+
+{#
+    TODO 1: valid todo
+#}
+
+{#
+    TODO 2: The issue is closed
+#}
+
+{#
+    TODO 3: The issue is non-existent
+#}
+
+<!-- TODO: malformed todo -->
+<!-- TODO 1: valid todo -->
+<!-- TODO 2: The issue is closed -->
+<!-- TODO 3: The issue is non-existent -->
diff --git a/testing/todocheck_test.go b/testing/todocheck_test.go
index 56cc34c..8a65a0f 100644
--- a/testing/todocheck_test.go
+++ b/testing/todocheck_test.go
@@ -894,6 +894,71 @@ func TestNimTodos(t *testing.T) {
 	}
 }
 
+func TestTwigTodos(t *testing.T) {
+	err := scenariobuilder.NewScenario().
+		WithBinary("../todocheck").
+		WithBasepath("./scenarios/twig").
+		WithConfig("./test_configs/no_issue_tracker.yaml").
+		WithIssueTracker(issuetracker.Jira).
+		WithIssue("1", issuetracker.StatusOpen).
+		WithIssue("2", issuetracker.StatusClosed).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeMalformed).
+				WithLocation("scenarios/twig/main.html.twig", 1).
+				ExpectLine("{# TODO: malformed todo #}")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeIssueClosed).
+				WithLocation("scenarios/twig/main.html.twig", 3).
+				ExpectLine("{# TODO 2: The issue is closed #}")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeNonExistentIssue).
+				WithLocation("scenarios/twig/main.html.twig", 4).
+				ExpectLine("{# TODO 3: The issue is non-existent #}")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeMalformed).
+				WithLocation("scenarios/twig/main.html.twig", 6).
+				ExpectLine("{#").
+				ExpectLine("    TODO: malformed todo").
+				ExpectLine("#}")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeIssueClosed).
+				WithLocation("scenarios/twig/main.html.twig", 14).
+				ExpectLine("{#").
+				ExpectLine("    TODO 2: The issue is closed").
+				ExpectLine("#}")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeNonExistentIssue).
+				WithLocation("scenarios/twig/main.html.twig", 18).
+				ExpectLine("{#").
+				ExpectLine("    TODO 3: The issue is non-existent").
+				ExpectLine("#}")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeMalformed).
+				WithLocation("scenarios/twig/main.html.twig", 22).
+				ExpectLine("<!-- TODO: malformed todo -->")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeIssueClosed).
+				WithLocation("scenarios/twig/main.html.twig", 24).
+				ExpectLine("<!-- TODO 2: The issue is closed -->")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeNonExistentIssue).
+				WithLocation("scenarios/twig/main.html.twig", 25).
+				ExpectLine("<!-- TODO 3: The issue is non-existent -->")).
+		Run()
+	if err != nil {
+		t.Errorf("%s", err)
+	}
+}
+
 func TestVueTodos(t *testing.T) {
 	err := scenariobuilder.NewScenario().
 		WithBinary("../todocheck").