| // Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'package:_fe_analyzer_shared/src/scanner/characters.dart'; |
| import 'package:analyzer/dart/ast/ast.dart'; |
| import 'package:analyzer/dart/ast/token.dart'; |
| import 'package:analyzer/source/line_info.dart'; |
| import 'package:analyzer/src/error/codes.dart'; |
| import 'package:analyzer/src/error/listener.dart'; |
| |
| /// Instances of the class `ToDoFinder` find to-do comments in Dart code. |
| class TodoFinder { |
| /// The diagnostic reporter by which to-do comments will be reported. |
| final DiagnosticReporter _diagnosticReporter; |
| |
| /// A regex for whitespace and comment markers to be removed from the text |
| /// of multiline TODOs in multiline comments. |
| final RegExp _commentNewlineAndMarker = RegExp('\\s*\\n\\s*\\*\\s*'); |
| |
| /// A regex for any character that is not a comment marker `/` or whitespace |
| /// used for finding the first "real" character of a comment to compare its |
| /// indentation for wrapped todos. |
| final RegExp _nonWhitespaceOrCommentMarker = RegExp('[^/ ]'); |
| |
| /// Initialize a newly created to-do finder to report to-do comments to the |
| /// given reporter. |
| /// |
| /// @param errorReporter the error reporter by which to-do comments will be |
| /// reported |
| TodoFinder(this._diagnosticReporter); |
| |
| /// Search the comments in the given compilation unit for to-do comments and |
| /// report an error for each. |
| /// |
| /// @param unit the compilation unit containing the to-do comments |
| void findIn(CompilationUnit unit) { |
| _gatherTodoComments(unit.beginToken, unit.lineInfo); |
| } |
| |
| /// Search the comment tokens reachable from the given token and create errors |
| /// for each to-do comment. |
| /// |
| /// @param token the head of the list of tokens being searched |
| void _gatherTodoComments(Token? token, LineInfo lineInfo) { |
| while (token != null && (!token.isEof || token.precedingComments != null)) { |
| Token? commentToken = token.precedingComments; |
| while (commentToken != null) { |
| if (commentToken.type == TokenType.SINGLE_LINE_COMMENT || |
| commentToken.type == TokenType.MULTI_LINE_COMMENT) { |
| commentToken = _scrapeTodoComment(commentToken, lineInfo); |
| } else { |
| commentToken = commentToken.next; |
| } |
| } |
| if (token.next == token) { |
| break; |
| } |
| token = token.next; |
| } |
| } |
| |
| /// Look for user defined tasks in comments starting [commentToken] and convert |
| /// them into info level analysis issues. |
| /// |
| /// Subsequent comments that are indented with an additional space are |
| /// considered continuations and will be included in a single analysis issue. |
| /// |
| /// Returns the next comment token to begin searching from (skipping over |
| /// any continuations). |
| Token? _scrapeTodoComment(Token commentToken, LineInfo lineInfo) { |
| // Track the comment that will be returned for looking for the next `todo`. |
| // This will be moved along if additional comments are consumed by multiline |
| // TODOs. |
| var nextComment = commentToken.next; |
| CharacterLocation? commentLocation; |
| |
| _TodoFinder todoFinder = _TodoFinder(commentToken.lexeme); |
| while (todoFinder.moveNext()) { |
| int matchOffset = todoFinder.offset; |
| String todoKind = todoFinder.todoKind; |
| String todoText = todoFinder.todoText; |
| |
| commentLocation ??= lineInfo.getLocation(commentToken.offset); |
| int offset = commentToken.offset + matchOffset; |
| int column = commentLocation.columnNumber + matchOffset; |
| int end = offset + todoText.length; |
| |
| if (commentToken.type == TokenType.MULTI_LINE_COMMENT) { |
| // Remove any `*/` and trim any trailing whitespace. |
| if (todoText.endsWith('*/')) { |
| todoText = todoText.substring(0, todoText.length - 2).trimRight(); |
| end = offset + todoText.length; |
| } |
| |
| // Replace out whitespace/comment markers to unwrap multiple lines. |
| // Do not reset length after this, as length must include all characters. |
| todoText = todoText.replaceAll(_commentNewlineAndMarker, ' '); |
| } else if (commentToken.type == TokenType.SINGLE_LINE_COMMENT) { |
| // Append any indented lines onto the end. |
| var line = commentLocation.lineNumber; |
| while (nextComment != null) { |
| var nextCommentLocation = lineInfo.getLocation(nextComment.offset); |
| var columnOfFirstNoneMarkerOrWhitespace = |
| nextCommentLocation.columnNumber + |
| nextComment.lexeme.indexOf(_nonWhitespaceOrCommentMarker); |
| |
| var isContinuation = |
| nextComment.type == TokenType.SINGLE_LINE_COMMENT && |
| // Don't consider Dartdocs that follow. |
| !nextComment.lexeme.startsWith('///') && |
| // Only consider TODOs on the very next line. |
| nextCommentLocation.lineNumber == line++ + 1 && |
| // Only consider comment tokens starting at the same column. |
| nextCommentLocation.columnNumber == |
| commentLocation.columnNumber && |
| // And indented more than the original 'todo' text. |
| columnOfFirstNoneMarkerOrWhitespace == column + 1 && |
| // And not their own todos. |
| !_TodoFinder(nextComment.lexeme).moveNext(); |
| if (!isContinuation) { |
| break; |
| } |
| |
| // Track the end of the continuation for the diagnostic range. |
| end = nextComment.end; |
| var lexemeTextOffset = |
| columnOfFirstNoneMarkerOrWhitespace - |
| nextCommentLocation.columnNumber; |
| var continuationText = nextComment.lexeme |
| .substring(lexemeTextOffset) |
| .trimRight(); |
| todoText = '$todoText $continuationText'; |
| nextComment = nextComment.next; |
| } |
| } |
| |
| _diagnosticReporter.report( |
| Todo.forKind(todoKind) |
| .withArguments(message: todoText) |
| .atOffset(offset: offset, length: end - offset), |
| ); |
| } |
| |
| return nextComment; |
| } |
| } |
| |
| class _TodoFinder { |
| final String s; |
| // We start at 1 to allow for the char before the first find to be \s, / or *. |
| int _startAt = 1; |
| int? _offset; |
| String? _todoText; |
| String? _todoKind; |
| |
| _TodoFinder(this.s); |
| |
| int get offset => _offset!; |
| String get todoKind => _todoKind!; |
| String get todoText => _todoText!; |
| |
| /// This matches the two common Dart task styles |
| /// |
| /// * `TODO`: |
| /// * `TODO`(username): |
| /// |
| /// As well as |
| /// * `TODO` |
| /// |
| /// But not |
| /// * `todo` |
| /// * `TODOS` |
| /// |
| /// It also supports wrapped TODOs where the next line is indented by a space: |
| /// |
| /// /** |
| /// * `TODO`(username): This line is |
| /// * wrapped onto the next line |
| /// */ |
| bool moveNext() { |
| // We stop 3 before so we can check the next 3 chars without checking |
| // lengths. |
| var end = s.length - 3; |
| for (int i = _startAt; i < end; i++) { |
| int char = s.codeUnitAt(i); |
| if (char >= $A && char <= $Z) { |
| if (char == $T && |
| s.codeUnitAt(i + 1) == $O && |
| s.codeUnitAt(i + 2) == $D && |
| s.codeUnitAt(i + 3) == $O) { |
| /// Found `TODO` |
| if (_check(i, i + 4)) return true; |
| } else if (char == $H && |
| s.codeUnitAt(i + 1) == $A && |
| s.codeUnitAt(i + 2) == $C && |
| s.codeUnitAt(i + 3) == $K) { |
| /// Found `HACK` |
| if (_check(i, i + 4)) return true; |
| } else if (char == $F && |
| s.length > i + 4 && |
| s.codeUnitAt(i + 1) == $I && |
| s.codeUnitAt(i + 2) == $X && |
| s.codeUnitAt(i + 3) == $M && |
| s.codeUnitAt(i + 4) == $E) { |
| /// Found `FIXME` |
| if (_check(i, i + 5)) return true; |
| } else if (char == $U && |
| s.length > i + 5 && |
| s.codeUnitAt(i + 1) == $N && |
| s.codeUnitAt(i + 2) == $D && |
| s.codeUnitAt(i + 3) == $O && |
| s.codeUnitAt(i + 4) == $N && |
| s.codeUnitAt(i + 5) == $E) { |
| /// Found `UNDONE` |
| if (_check(i, i + 6)) return true; |
| } |
| } |
| } |
| |
| return false; |
| } |
| |
| bool _check(int from, int to) { |
| int charBefore = s.codeUnitAt(from - 1); |
| if (charBefore != $SPACE && |
| charBefore != $TAB && |
| charBefore != $LF && |
| charBefore != $CR && |
| charBefore != $STAR && |
| charBefore != $SLASH) { |
| // Doesn't start with \s, / or *. |
| return false; |
| } |
| if (s.length == to) { |
| // Line ends with this. |
| _match(from, to, to); |
| return true; |
| } |
| |
| int charAfter = s.codeUnitAt(to); |
| // Not allowed to match [A-Za-z0-9_]. |
| if (charAfter >= $0 && charAfter <= $9) return false; |
| if (charAfter >= $a && charAfter <= $z) return false; |
| if (charAfter >= $A && charAfter <= $Z) return false; |
| if (charAfter == $_) return false; |
| |
| int scanFrom = to + 1; |
| while (true) { |
| int? foundLinebreakAt; |
| for (int i = scanFrom; i < s.length; i++) { |
| int char = s.codeUnitAt(i); |
| if (char == $CR || char == $LF) { |
| foundLinebreakAt = i; |
| break; |
| } |
| } |
| |
| if (foundLinebreakAt == null) { |
| // No line breaks - we match until the end. |
| _match(from, to, s.length); |
| return true; |
| } |
| |
| // Possibly match the next line too. |
| int includeUntil = foundLinebreakAt; |
| int nextLineAt = foundLinebreakAt; |
| if (s.codeUnitAt(nextLineAt) == $CR && s.length > nextLineAt + 1) { |
| // Allow \n after \r. |
| if (s.codeUnitAt(nextLineAt + 1) == $LF) { |
| nextLineAt++; |
| } |
| } |
| |
| int i = nextLineAt + 1; |
| while (i < s.length) { |
| int char = s.codeUnitAt(i); |
| if (char != $SPACE && char != $TAB && char != $LF && char != $CR) { |
| break; |
| } |
| i++; |
| } |
| if (!(s.length > i + 2 && |
| s.codeUnitAt(i) == $STAR && |
| s.codeUnitAt(i + 1) == $SPACE && |
| s.codeUnitAt(i + 2) == $SPACE)) { |
| // This line isn't some number of whitespace, then a * then 2 spaces. |
| // We don't include it. |
| _match(from, to, includeUntil); |
| return true; |
| } |
| // Include this line too. |
| scanFrom = i + 3; |
| } |
| } |
| |
| void _match(int from, int kindTo, int finalTo) { |
| _offset = from; |
| _todoText = s.substring(from, finalTo); |
| _todoKind = s.substring(from, kindTo); |
| _startAt = finalTo; |
| } |
| } |