diff --git a/README.md b/README.md index 899acc0..98a9e3c 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,7 @@ Based on this, here are the supported languages: | 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 | diff --git a/matchers/matchers.go b/matchers/matchers.go index 5d5c78f..318f0fd 100644 --- a/matchers/matchers.go +++ b/matchers/matchers.go @@ -5,6 +5,7 @@ import ( "sync" "github.com/preslavmihaylov/todocheck/matchers/groovy" + "github.com/preslavmihaylov/todocheck/matchers/nim" "github.com/preslavmihaylov/todocheck/matchers/php" "github.com/preslavmihaylov/todocheck/matchers/python" "github.com/preslavmihaylov/todocheck/matchers/scripts" @@ -125,7 +126,6 @@ var ( return groovy.NewCommentMatcher(callback) }, } - vueMatcherFactory = &matcherFactory{ func() func([]string) TodoMatcher { var once sync.Once @@ -142,6 +142,22 @@ var ( return vue.NewCommentMatcher(callback) }, } + nimMatcherFactory = &matcherFactory{ + func() func([]string) TodoMatcher { + var once sync.Once + var matcher TodoMatcher + + return func(customTodos []string) TodoMatcher { + once.Do(func() { + matcher = nim.NewTodoMatcher(customTodos) + }) + return matcher + } + }(), + func(callback state.CommentCallback) CommentMatcher { + return nim.NewCommentMatcher(callback) + }, + } ) var supportedMatchers = map[string]*matcherFactory{ @@ -183,6 +199,11 @@ var supportedMatchers = map[string]*matcherFactory{ // file types, supporting js, html and css comments ".vue": vueMatcherFactory, + + // file types, supporting nim comments + ".nim": nimMatcherFactory, + ".nims": nimMatcherFactory, + ".nimble": nimMatcherFactory, } // TodoMatcherForFile gets the correct todo matcher for the given filename diff --git a/matchers/nim/comments.go b/matchers/nim/comments.go new file mode 100644 index 0000000..3756cfc --- /dev/null +++ b/matchers/nim/comments.go @@ -0,0 +1,121 @@ +package nim + +import ( + "github.com/preslavmihaylov/todocheck/matchers/state" +) + +func NewCommentMatcher(callback state.CommentCallback) *CommentMatcher { + return &CommentMatcher{ + callback: callback, + depth: 0, + } +} + +type CommentMatcher struct { + callback state.CommentCallback + buffer string + lines []string + lineCount int + stringToken rune + depth int +} + +func (m *CommentMatcher) NonCommentState( + filename, + line string, + lineCount int, + prevToken, currToken, nextToken rune, +) (state.CommentState, error) { + if isSingleLineOpener(currToken, nextToken) { + m.buffer += string(currToken) + return state.SingleLineComment, nil + } else if isMultiLineOpener(currToken, nextToken) { + m.buffer += string(currToken) + m.lines = []string{line} + m.lineCount = lineCount + m.depth += 1 + return state.MultiLineComment, nil + } else if currToken == '"' || currToken == '\'' || currToken == '`' { + m.stringToken = currToken + return state.String, nil + } else { + return state.NonComment, nil + } +} + +func (m *CommentMatcher) MultiLineCommentState( + filename, line string, linecnt int, prevToken, currToken, nextToken rune, +) (state.CommentState, error) { + m.buffer += string(currToken) + if isMultiLineOpener(currToken, nextToken) { + // Capture nested comments + m.depth += 1 + } else if isMultiLineCloser(currToken, nextToken) { + m.depth -= 1 + } + + if m.depth == 0 { + err := m.callback(m.buffer, filename, m.lines, m.lineCount) + if err != nil { + return state.NonComment, err + } + + m.reset() + return state.NonComment, nil + } + + if prevToken == '\n' { + m.lines = append(m.lines, line) + } + + return state.MultiLineComment, nil +} + +func (m *CommentMatcher) SingleLineCommentState( + filename, line string, linecnt int, prevToken, currToken, nextToken rune, +) (state.CommentState, error) { + if currToken == '\n' { + // Reach end of line i.e. end of comment + err := m.callback(m.buffer, filename, []string{line}, linecnt) + if err != nil { + return state.NonComment, err + } + + m.reset() + return state.NonComment, nil + } + + m.buffer += string(currToken) + return state.SingleLineComment, nil +} + +// StringState for standard 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 +} + +func isSingleLineOpener(currToken, nextToken rune) bool { + return currToken == '#' && nextToken != '[' +} + +func isMultiLineOpener(currToken, nextToken rune) bool { + return currToken == '#' && nextToken == '[' +} + +func isMultiLineCloser(currToken, nextToken rune) bool { + return currToken == ']' && nextToken == '#' +} + +func (m *CommentMatcher) reset() { + m.buffer = "" + m.lines = nil + m.lineCount = 0 + m.stringToken = 0 + m.depth = 0 +} diff --git a/matchers/nim/doc.go b/matchers/nim/doc.go new file mode 100644 index 0000000..a6e2172 --- /dev/null +++ b/matchers/nim/doc.go @@ -0,0 +1,3 @@ +// Package scripts contains a todo matcher & comments matcher for Nim. +// Source files contain # for single-line comments, and nestable #[ ]# multiline comments +package nim diff --git a/matchers/nim/todomatcher.go b/matchers/nim/todomatcher.go new file mode 100644 index 0000000..6617dda --- /dev/null +++ b/matchers/nim/todomatcher.go @@ -0,0 +1,64 @@ +package nim + +import ( + "regexp" + + "github.com/preslavmihaylov/todocheck/common" + "github.com/preslavmihaylov/todocheck/matchers/errors" +) + +// NewTodoMatcher for Nim comments +func NewTodoMatcher(todos []string) *TodoMatcher { + pattern := common.ArrayAsRegexAnyMatchExpression(todos) + + // Single line + singleLineTodoPattern := regexp.MustCompile(`^\s*#[^\[].*` + pattern) + singleLineValidTodoPattern := regexp.MustCompile(`^\s*#[^\[]` + pattern + ` (#?[a-zA-Z0-9\-]+):.*`) + + // Multiline line + multiLineTodoPattern := regexp.MustCompile(`(?s)^\s*(#\[).*` + pattern) + multiLineValidTodoPattern := regexp.MustCompile(`(?s)^\s*(#\[).*` + pattern + ` (#?[a-zA-Z0-9\-]+):.*`) + + return &TodoMatcher{ + singleLineTodoPattern: singleLineTodoPattern, + singleLineValidTodoPattern: singleLineValidTodoPattern, + multiLineTodoPattern: multiLineTodoPattern, + multiLineValidTodoPattern: multiLineValidTodoPattern, + } +} + +// TodoMatcher for Nim comments +type TodoMatcher struct { + singleLineTodoPattern *regexp.Regexp + singleLineValidTodoPattern *regexp.Regexp + multiLineTodoPattern *regexp.Regexp + multiLineValidTodoPattern *regexp.Regexp +} + +// IsMatch checks if the current expression matches a Nim comment +func (m *TodoMatcher) IsMatch(expr string) bool { + return m.singleLineTodoPattern.Match([]byte(expr)) || m.multiLineTodoPattern.Match([]byte(expr)) +} + +// IsValid checks if the expression is a valid todo comment +func (m *TodoMatcher) IsValid(expr string) bool { + return m.singleLineValidTodoPattern.Match([]byte(expr)) || 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 + } + + singleLineRes := m.singleLineValidTodoPattern.FindStringSubmatch(expr) + multiLineRes := m.multiLineValidTodoPattern.FindStringSubmatch(expr) + if len(singleLineRes) >= 2 { + return singleLineRes[1], nil + } else if len(multiLineRes) >= 3 { + return multiLineRes[2], nil + } + + panic("Invariant violated. No issue reference found in valid TODO") +} diff --git a/testing/scenarios/nim/main.nim b/testing/scenarios/nim/main.nim new file mode 100644 index 0000000..546de2b --- /dev/null +++ b/testing/scenarios/nim/main.nim @@ -0,0 +1,14 @@ +# This is a single-line malformed TODO + +# TODO 1: This is a valid todo comment + +when isMainModule: +# TODO 234: Invalid todo, with a closed issue + discard "Hello World" + +#[ TODO 2: Another valid todo ]# + + discard "Magic here, magic there!" +#[ +#[ TODO 3: There is also a valid nested todo here! ]# +]# \ No newline at end of file diff --git a/testing/todocheck_test.go b/testing/todocheck_test.go index b80f77e..fa04ace 100644 --- a/testing/todocheck_test.go +++ b/testing/todocheck_test.go @@ -765,6 +765,44 @@ func TestPrintingVersionFlagStopsProgram(t *testing.T) { } } +func TestNimTodos(t *testing.T) { + err := scenariobuilder.NewScenario(). + WithBinary("../todocheck"). + WithBasepath("./scenarios/nim"). + WithConfig("./test_configs/no_issue_tracker.yaml"). + WithIssueTracker(issuetracker.Jira). + WithIssue("1", issuetracker.StatusOpen). + WithIssue("2", issuetracker.StatusClosed). + WithIssue("3", issuetracker.StatusClosed). + WithIssue("234", issuetracker.StatusClosed). + ExpectTodoErr( + scenariobuilder.NewTodoErr(). + WithType(errors.TODOErrTypeMalformed). + WithLocation("scenarios/nim/main.nim", 1). + ExpectLine("# This is a single-line malformed TODO")). + ExpectTodoErr( + scenariobuilder.NewTodoErr(). + WithType(errors.TODOErrTypeIssueClosed). + WithLocation("scenarios/nim/main.nim", 6). + ExpectLine("# TODO 234: Invalid todo, with a closed issue")). + ExpectTodoErr( + scenariobuilder.NewTodoErr(). + WithType(errors.TODOErrTypeIssueClosed). + WithLocation("scenarios/nim/main.nim", 9). + ExpectLine("#[ TODO 2: Another valid todo ]#")). + ExpectTodoErr( + scenariobuilder.NewTodoErr(). + WithType(errors.TODOErrTypeIssueClosed). + WithLocation("scenarios/nim/main.nim", 12). + ExpectLine("#["). + ExpectLine("#[ TODO 3: There is also a valid nested todo here! ]#"). + ExpectLine("]#")). + Run() + if err != nil { + t.Errorf("%s", err) + } +} + func TestVueTodos(t *testing.T) { err := scenariobuilder.NewScenario(). WithBinary("../todocheck").