Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(131)

Side by Side Diff: pkg/analyzer_experimental/lib/src/services/formatter_impl.dart

Issue 23480055: Formatter sanity-checking (via token stream verification). (Closed) Base URL: http://dart.googlecode.com/svn/branches/bleeding_edge/dart/
Patch Set: Created 7 years, 3 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
OLDNEW
1 // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 1 // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
2 // for details. All rights reserved. Use of this source code is governed by a 2 // for details. All rights reserved. Use of this source code is governed by a
3 // BSD-style license that can be found in the LICENSE file. 3 // BSD-style license that can be found in the LICENSE file.
4 4
5 library formatter_impl; 5 library formatter_impl;
6 6
7 import 'dart:math'; 7 import 'dart:math';
8 8
9 import 'package:analyzer_experimental/analyzer.dart'; 9 import 'package:analyzer_experimental/analyzer.dart';
10 import 'package:analyzer_experimental/src/generated/parser.dart'; 10 import 'package:analyzer_experimental/src/generated/parser.dart';
11 import 'package:analyzer_experimental/src/generated/scanner.dart'; 11 import 'package:analyzer_experimental/src/generated/scanner.dart';
12 import 'package:analyzer_experimental/src/generated/source.dart'; 12 import 'package:analyzer_experimental/src/generated/source.dart';
13 import 'package:analyzer_experimental/src/services/writer.dart'; 13 import 'package:analyzer_experimental/src/services/writer.dart';
14 14
15 /// OS line separator. --- TODO(pquitslund): may not be necessary
16 const NEW_LINE = '\n' ; //Platform.pathSeparator;
17
18 /// Formatter options. 15 /// Formatter options.
19 class FormatterOptions { 16 class FormatterOptions {
20 17
21 /// Create formatter options with defaults derived (where defined) from 18 /// Create formatter options with defaults derived (where defined) from
22 /// the style guide: <http://www.dartlang.org/articles/style-guide/>. 19 /// the style guide: <http://www.dartlang.org/articles/style-guide/>.
23 const FormatterOptions({this.initialIndentationLevel: 0, 20 const FormatterOptions({this.initialIndentationLevel: 0,
24 this.spacesPerIndent: 2, 21 this.spacesPerIndent: 2,
25 this.lineSeparator: NEW_LINE, 22 this.lineSeparator: NEW_LINE,
26 this.pageWidth: 80, 23 this.pageWidth: 80,
27 this.tabsForIndent: false, 24 this.tabsForIndent: false,
28 this.tabSize: 2}); 25 this.tabSize: 2});
29 26
30 final String lineSeparator; 27 final String lineSeparator;
31 final int initialIndentationLevel; 28 final int initialIndentationLevel;
32 final int spacesPerIndent; 29 final int spacesPerIndent;
33 final int tabSize; 30 final int tabSize;
34 final bool tabsForIndent; 31 final bool tabsForIndent;
35 final int pageWidth; 32 final int pageWidth;
36 } 33 }
37 34
38 35
39 /// Thrown when an error occurs in formatting. 36 /// Thrown when an error occurs in formatting.
40 class FormatterException implements Exception { 37 class FormatterException implements Exception {
41 38
42 /// A message describing the error. 39 /// A message describing the error.
43 final String message; 40 final String message;
44 41
45 /// Creates a new FormatterException with an optional error [message]. 42 /// Creates a new FormatterException with an optional error [message].
46 const FormatterException([this.message = '']); 43 const FormatterException([this.message = 'FormatterException']);
47 44
48 FormatterException.forError(List<AnalysisError> errors) : 45 FormatterException.forError(List<AnalysisError> errors, [LineInfo line]) :
49 // TODO(pquitslund): add descriptive message based on errors 46 message = _createMessage(errors);
50 message = 'an analysis error occured during format';
51 47
52 String toString() => 'FormatterException: $message'; 48 static String _createMessage(errors) {
49 //TODO(pquitslund): consider a verbosity flag to add/suppress details
50 var errorCode = errors[0].errorCode;
51 var phase = errorCode is ParserErrorCode ? 'parsing' : 'scanning';
52 return 'An error occured while ${phase} (${errorCode.name}).';
53 }
54
55 String toString() => '$message';
53 } 56 }
54 57
55 /// Specifies the kind of code snippet to format. 58 /// Specifies the kind of code snippet to format.
56 class CodeKind { 59 class CodeKind {
57 60
58 final int _index; 61 final int _index;
59 62
60 const CodeKind._(this._index); 63 const CodeKind._(this._index);
61 64
62 /// A compilation unit snippet. 65 /// A compilation unit snippet.
(...skipping 14 matching lines...) Expand all
77 /// [source] string, optionally providing an [indentationLevel]. 80 /// [source] string, optionally providing an [indentationLevel].
78 String format(CodeKind kind, String source, {int offset, int end, 81 String format(CodeKind kind, String source, {int offset, int end,
79 int indentationLevel: 0}); 82 int indentationLevel: 0});
80 83
81 } 84 }
82 85
83 class CodeFormatterImpl implements CodeFormatter, AnalysisErrorListener { 86 class CodeFormatterImpl implements CodeFormatter, AnalysisErrorListener {
84 87
85 final FormatterOptions options; 88 final FormatterOptions options;
86 final errors = <AnalysisError>[]; 89 final errors = <AnalysisError>[];
90 final whitespace = new RegExp(r'[\s]+');
87 91
88 LineInfo lineInfo; 92 LineInfo lineInfo;
89 93
90 CodeFormatterImpl(this.options); 94 CodeFormatterImpl(this.options);
91 95
92 String format(CodeKind kind, String source, {int offset, int end, 96 String format(CodeKind kind, String source, {int offset, int end,
93 int indentationLevel: 0}) { 97 int indentationLevel: 0}) {
94 98
95 var start = tokenize(source); 99 var start = tokenize(source);
96 checkForErrors(); 100 checkForErrors();
97 101
98 var node = parse(kind, start); 102 var node = parse(kind, start);
99 checkForErrors(); 103 checkForErrors();
100 104
101 var formatter = new SourceVisitor(options, lineInfo); 105 var formatter = new SourceVisitor(options, lineInfo);
102 node.accept(formatter); 106 node.accept(formatter);
103 107
108 checkTokenStreams(start, tokenize(source));
Brian Wilkerson 2013/09/09 22:21:47 It looks like you're testing to see whether the sc
pquitslund 2013/09/10 18:24:13 Hilarious. Looks like I did one undo too many! T
109
104 return formatter.writer.toString(); 110 return formatter.writer.toString();
105 } 111 }
106 112
113 checkTokenStreams(Token t1, Token t2) =>
114 new TokenStreamComparator(lineInfo, t1, t2).verifyEquals();
115
107 ASTNode parse(CodeKind kind, Token start) { 116 ASTNode parse(CodeKind kind, Token start) {
108 117
109 var parser = new Parser(null, this); 118 var parser = new Parser(null, this);
110 119
111 switch (kind) { 120 switch (kind) {
112 case CodeKind.COMPILATION_UNIT: 121 case CodeKind.COMPILATION_UNIT:
113 return parser.parseCompilationUnit(start); 122 return parser.parseCompilationUnit(start);
114 case CodeKind.STATEMENT: 123 case CodeKind.STATEMENT:
115 return parser.parseStatement(start); 124 return parser.parseStatement(start);
116 } 125 }
117 126
118 throw new FormatterException('Unsupported format kind: $kind'); 127 throw new FormatterException('Unsupported format kind: $kind');
119 } 128 }
120 129
121 void checkForErrors() { 130 checkForErrors() {
122 if (errors.length > 0) { 131 if (errors.length > 0) {
123 throw new FormatterException.forError(errors); 132 throw new FormatterException.forError(errors);
124 } 133 }
125 } 134 }
126 135
127 void onError(AnalysisError error) { 136 onError(AnalysisError error) {
128 errors.add(error); 137 errors.add(error);
129 } 138 }
130 139
131 Token tokenize(String source) { 140 Token tokenize(String source) {
132 var scanner = new StringScanner(null, source, this); 141 var scanner = new StringScanner(null, source, this);
133 var token = scanner.tokenize(); 142 var token = scanner.tokenize();
134 lineInfo = new LineInfo(scanner.lineStarts); 143 lineInfo = new LineInfo(scanner.lineStarts);
135 return token; 144 return token;
136 } 145 }
137 146
138 } 147 }
139 148
140 149
150 // Compares two token streams. Used for sanity checking formatted results.
151 class TokenStreamComparator {
152
153 final LineInfo lineInfo;
154 Token token1, token2;
155
156 TokenStreamComparator(this.lineInfo, this.token1, this.token2);
157
158 /// Verify that these two token streams are equal.
159 verifyEquals() {
160 while (!isEOF(token1)) {
161 checkPrecedingComments();
162 if (!checkTokens()) {
163 throwNotEqualException(token1, token2);
164 }
165 advance();
166
167 }
168 if (!isEOF(token2)) {
169 throw new FormatterException(
170 'Expected "EOF" but got "${token2}".');
171 }
172 }
173
174 checkPrecedingComments() {
175 var comment1 = token1.precedingComments;
176 var comment2 = token2.precedingComments;
177 while (comment1 != null) {
178 if (comment2 == null) {
179 throw new FormatterException(
180 'Expected comment, "${comment1}", at ${describeLocation(token1)}, '
181 'but got none.');
182 }
183 if (!equivalentComments(comment1, comment2)) {
184 throwNotEqualException(comment1, comment2);
185 }
186 comment1 = comment1.next;
187 comment2 = comment2.next;
188 }
189 if (comment2 != null) {
190 throw new FormatterException(
191 'Unexpected comment, "${comment2}", at ${describeLocation(token2)}.');
192 }
193 }
194
195 bool equivalentComments(Token comment1, Token comment2) =>
196 comment1.lexeme.trim() == comment2.lexeme.trim();
197
198 throwNotEqualException(t1, t2) {
199 throw new FormatterException(
200 'Expected "${t1}" but got "${t2}", at ${describeLocation(t1)}.');
201 }
202
203 String describeLocation(Token token) => lineInfo == null ? '<unknown>' :
204 'Line: ${lineInfo.getLocation(token.offset).lineNumber}, '
205 'Column: ${lineInfo.getLocation(token.offset).columnNumber}';
206
207 advance() {
208 token1 = token1.next;
209 token2 = token2.next;
210 }
211
212 bool checkTokens() {
213 if (token1 == null || token2 == null) {
214 return false;
215 }
216 if (token1 == token2 || token1.lexeme == token2.lexeme) {
217 return true;
218 }
219
220 // '[' ']' => '[]'
221 if (isOPEN_SQ_BRACKET(token1) && isCLOSE_SQUARE_BRACKET(token1.next)) {
222 if (isINDEX(token2)) {
223 token1 = token1.next;
224 return true;
225 }
226 }
227 // '>' '>' => '>>'
228 if (isGT(token1) && isGT(token1.next)) {
229 if (isGT_GT(token2)) {
230 token1 = token1.next;
231 return true;
232 }
233 }
234
235 return false;
236 }
237
238 }
239
240 /// Test if this token is an EOF token.
241 bool isEOF(Token token) => token != null && token.type == TokenType.EOF;
242
243 /// Test if this token is a GT token.
244 bool isGT(Token token) => token != null && token.type == TokenType.GT;
245
246 /// Test if this token is a GT_GT token.
247 bool isGT_GT(Token token) => token != null && token.type == TokenType.GT_GT;
248
249 /// Test if this token is an INDEX token.
250 bool isINDEX(Token token) => token != null && token.type == TokenType.INDEX;
251
252 /// Test if this token is a OPEN_SQUARE_BRACKET token.
253 bool isOPEN_SQ_BRACKET(Token token) =>
254 token != null && token.type == TokenType.OPEN_SQUARE_BRACKET;
255
256 /// Test if this token is a CLOSE_SQUARE_BRACKET token.
257 bool isCLOSE_SQUARE_BRACKET(Token token) =>
258 token != null && token.type == TokenType.CLOSE_SQUARE_BRACKET;
259
Brian Wilkerson 2013/09/09 22:21:47 The parser uses a similar method, but passes the e
pquitslund 2013/09/10 18:24:13 Done.
141 /// An AST visitor that drives formatting heuristics. 260 /// An AST visitor that drives formatting heuristics.
142 class SourceVisitor implements ASTVisitor { 261 class SourceVisitor implements ASTVisitor {
143 262
144 /// The writer to which the source is to be written. 263 /// The writer to which the source is to be written.
145 final SourceWriter writer; 264 final SourceWriter writer;
146 265
147 /// Cached line info for calculating blank lines. 266 /// Cached line info for calculating blank lines.
148 LineInfo lineInfo; 267 LineInfo lineInfo;
149 268
150 /// Cached previous token for calculating preceding whitespace. 269 /// Cached previous token for calculating preceding whitespace.
151 Token previousToken; 270 Token previousToken;
152 271
153 /// A flag to indicate that a newline should be emitted before the next token. 272 /// A flag to indicate that a newline should be emitted before the next token.
154 bool needsNewline = false; 273 bool needsNewline = false;
155 274
156 /// Used for matching EOL comments 275 /// Used for matching EOL comments
157 final twoSlashes = new RegExp(r'//[^/]'); 276 final twoSlashes = new RegExp(r'//[^/]');
158 277
159 /// Initialize a newly created visitor to write source code representing 278 /// Initialize a newly created visitor to write source code representing
160 /// the visited nodes to the given [writer]. 279 /// the visited nodes to the given [writer].
161 SourceVisitor(FormatterOptions options, this.lineInfo) : 280 SourceVisitor(FormatterOptions options, this.lineInfo) :
162 writer = new SourceWriter(indentCount: options.initialIndentationLevel, 281 writer = new SourceWriter(indentCount: options.initialIndentationLevel,
163 lineSeparator: options.lineSeparator); 282 lineSeparator: options.lineSeparator);
164 283
165 visitAdjacentStrings(AdjacentStrings node) { 284 visitAdjacentStrings(AdjacentStrings node) {
166 visitNodes(node.strings, separatedBy: space); 285 visitNodes(node.strings, separatedBy: space);
167 } 286 }
168 287
(...skipping 913 matching lines...) Expand 10 before | Expand all | Expand 10 after
1082 /// Indent. 1201 /// Indent.
1083 indent() { 1202 indent() {
1084 writer.indent(); 1203 writer.indent();
1085 } 1204 }
1086 1205
1087 /// Unindent 1206 /// Unindent
1088 unindent() { 1207 unindent() {
1089 writer.unindent(); 1208 writer.unindent();
1090 } 1209 }
1091 1210
1092 1211
1093 /// Emit any detected comments and newlines or a minimum as specified 1212 /// Emit any detected comments and newlines or a minimum as specified
1094 /// by [min]. 1213 /// by [min].
1095 int emitPrecedingCommentsAndNewlines(Token token, {min: 0}) { 1214 int emitPrecedingCommentsAndNewlines(Token token, {min: 0}) {
1096 1215
1097 var comment = token.precedingComments; 1216 var comment = token.precedingComments;
1098 var currentToken = comment != null ? comment : token; 1217 var currentToken = comment != null ? comment : token;
1099 1218
1100 //Handle EOLs before newlines 1219 //Handle EOLs before newlines
1101 if (isAtEOL(comment)) { 1220 if (isAtEOL(comment)) {
1102 emitComment(comment, previousToken); 1221 emitComment(comment, previousToken);
1103 comment = comment.next; 1222 comment = comment.next;
1104 currentToken = comment != null ? comment : token; 1223 currentToken = comment != null ? comment : token;
1105 } 1224 }
1106 1225
1107 var lines = max(min, countNewlinesBetween(previousToken, currentToken)); 1226 var lines = max(min, countNewlinesBetween(previousToken, currentToken));
1108 writer.newlines(lines); 1227 writer.newlines(lines);
1109 1228
1110 var previousToken = currentToken.previous; 1229 var previousToken = currentToken.previous;
1111 1230
1112 while (comment != null) { 1231 while (comment != null) {
1113 1232
1114 emitComment(comment, previousToken); 1233 emitComment(comment, previousToken);
1115 1234
1116 var nextToken = comment.next != null ? comment.next : token; 1235 var nextToken = comment.next != null ? comment.next : token;
1117 var newlines = calculateNewlinesBetweenComments(comment, nextToken); 1236 var newlines = calculateNewlinesBetweenComments(comment, nextToken);
1118 if (newlines > 0) { 1237 if (newlines > 0) {
1119 writer.newlines(newlines); 1238 writer.newlines(newlines);
1120 lines += newlines; 1239 lines += newlines;
1121 } else if (!isEOF(token)) { 1240 } else if (!isEOF(token)) {
1122 space(); 1241 space();
1123 } 1242 }
1124 1243
1125 previousToken = comment; 1244 previousToken = comment;
1126 comment = comment.next; 1245 comment = comment.next;
1127 } 1246 }
1128 1247
1129 previousToken = token; 1248 previousToken = token;
1130 return lines; 1249 return lines;
1131 } 1250 }
1132 1251
1133 /// Test if this [comment] is at the end of a line. 1252 /// Test if this [comment] is at the end of a line.
1134 bool isAtEOL(Token comment) => 1253 bool isAtEOL(Token comment) =>
1135 comment != null && comment.toString().trim().startsWith(twoSlashes) && 1254 comment != null && comment.toString().trim().startsWith(twoSlashes) &&
1136 sameLine(comment, previousToken); 1255 sameLine(comment, previousToken);
1137 1256
1138 /// Emit this [comment], inserting leading whitespace if appropriate. 1257 /// Emit this [comment], inserting leading whitespace if appropriate.
1139 emitComment(Token comment, Token previousToken) { 1258 emitComment(Token comment, Token previousToken) {
1140 if (!writer.currentLine.isWhitespace() && !isBlock(comment)) { 1259 if (!writer.currentLine.isWhitespace() && !isBlock(comment)) {
1141 var ws = countSpacesBetween(previousToken, comment); 1260 var ws = countSpacesBetween(previousToken, comment);
1142 // Preserve one space but no more 1261 // Preserve one space but no more
1143 if (ws > 0) { 1262 if (ws > 0) {
1144 space(); 1263 space();
1145 } 1264 }
1146 } 1265 }
1147 1266
1148 append(comment.toString().trim()); 1267 append(comment.toString().trim());
1149 } 1268 }
1150 1269
1151 /// Test if this token is an EOF token.
1152 bool isEOF(Token token) => token.type == TokenType.EOF;
1153
1154 /// Count spaces between these tokens. Tokens on different lines return 0. 1270 /// Count spaces between these tokens. Tokens on different lines return 0.
1155 int countSpacesBetween(Token last, Token current) => isEOF(last) || 1271 int countSpacesBetween(Token last, Token current) => isEOF(last) ||
1156 countNewlinesBetween(last, current) > 0 ? 0 : current.offset - last.end; 1272 countNewlinesBetween(last, current) > 0 ? 0 : current.offset - last.end;
1157 1273
1158 /// Count the blanks between these two nodes. 1274 /// Count the blanks between these two nodes.
1159 int countBlankLinesBetween(ASTNode lastNode, ASTNode currentNode) => 1275 int countBlankLinesBetween(ASTNode lastNode, ASTNode currentNode) =>
1160 countNewlinesBetween(lastNode.endToken, currentNode.beginToken); 1276 countNewlinesBetween(lastNode.endToken, currentNode.beginToken);
1161 1277
1162 /// Count newlines preceeding this [node]. 1278 /// Count newlines preceeding this [node].
1163 int countPrecedingNewlines(ASTNode node) => 1279 int countPrecedingNewlines(ASTNode node) =>
1164 countNewlinesBetween(node.beginToken.previous, node.beginToken); 1280 countNewlinesBetween(node.beginToken.previous, node.beginToken);
1165 1281
1166 /// Count newlines succeeding this [node]. 1282 /// Count newlines succeeding this [node].
1167 int countSucceedingNewlines(ASTNode node) => node == null ? 0 : 1283 int countSucceedingNewlines(ASTNode node) => node == null ? 0 :
1168 countNewlinesBetween(node.endToken, node.endToken.next); 1284 countNewlinesBetween(node.endToken, node.endToken.next);
1169 1285
1170 /// Count the blanks between these two tokens. 1286 /// Count the blanks between these two tokens.
1171 int countNewlinesBetween(Token last, Token current) { 1287 int countNewlinesBetween(Token last, Token current) {
1172 if (last == null || current == null) { 1288 if (last == null || current == null) {
1173 return 0; 1289 return 0;
1174 } 1290 }
1175 1291
1176 return linesBetween(last.end - 1, current.offset); 1292 return linesBetween(last.end - 1, current.offset);
1177 } 1293 }
1178 1294
1179 /// Calculate the newlines that should separate these comments. 1295 /// Calculate the newlines that should separate these comments.
1180 int calculateNewlinesBetweenComments(Token last, Token current) { 1296 int calculateNewlinesBetweenComments(Token last, Token current) {
1181 // Insist on a newline after doc comments or single line comments 1297 // Insist on a newline after doc comments or single line comments
1182 // (NOTE that EOL comments have already been processed). 1298 // (NOTE that EOL comments have already been processed).
1183 if (isOldSingleLineDocComment(last) || isSingleLineComment(last)) { 1299 if (isOldSingleLineDocComment(last) || isSingleLineComment(last)) {
1184 return max(1, countNewlinesBetween(last, current)); 1300 return max(1, countNewlinesBetween(last, current));
1185 } else { 1301 } else {
1186 return countNewlinesBetween(last, current); 1302 return countNewlinesBetween(last, current);
1187 } 1303 }
1188 } 1304 }
1189 1305
1190 /// Single line multi-line comments (e.g., '/** like this */'). 1306 /// Single line multi-line comments (e.g., '/** like this */').
1191 bool isOldSingleLineDocComment(Token comment) => 1307 bool isOldSingleLineDocComment(Token comment) =>
1192 comment.lexeme.startsWith(r'/**') && singleLine(comment); 1308 comment.lexeme.startsWith(r'/**') && singleLine(comment);
1193 1309
1194 /// Test if this [token] spans just one line. 1310 /// Test if this [token] spans just one line.
1195 bool singleLine(Token token) => linesBetween(token.offset, token.end) < 1; 1311 bool singleLine(Token token) => linesBetween(token.offset, token.end) < 1;
1196 1312
1197 /// Test if token [first] is on the same line as [second]. 1313 /// Test if token [first] is on the same line as [second].
1198 bool sameLine(Token first, Token second) => 1314 bool sameLine(Token first, Token second) =>
1199 countNewlinesBetween(first, second) == 0; 1315 countNewlinesBetween(first, second) == 0;
1200 1316
1201 /// Test if this is a multi-line [comment] (e.g., '/* ...' or '/** ...') 1317 /// Test if this is a multi-line [comment] (e.g., '/* ...' or '/** ...')
1202 bool isMultiLineComment(Token comment) => 1318 bool isMultiLineComment(Token comment) =>
1203 comment.type == TokenType.MULTI_LINE_COMMENT; 1319 comment.type == TokenType.MULTI_LINE_COMMENT;
1204 1320
1205 /// Test if this is a single-line [comment] (e.g., '// ...') 1321 /// Test if this is a single-line [comment] (e.g., '// ...')
1206 bool isSingleLineComment(Token comment) => 1322 bool isSingleLineComment(Token comment) =>
1207 comment.type == TokenType.SINGLE_LINE_COMMENT; 1323 comment.type == TokenType.SINGLE_LINE_COMMENT;
1208 1324
1209 /// Test if this [comment] is a block comment (e.g., '/* like this */').. 1325 /// Test if this [comment] is a block comment (e.g., '/* like this */')..
1210 bool isBlock(Token comment) => 1326 bool isBlock(Token comment) =>
1211 isMultiLineComment(comment) && singleLine(comment); 1327 isMultiLineComment(comment) && singleLine(comment);
1212 1328
1213 /// Count the lines between two offsets. 1329 /// Count the lines between two offsets.
1214 int linesBetween(int lastOffset, int currentOffset) { 1330 int linesBetween(int lastOffset, int currentOffset) {
1215 var lastLine = 1331 var lastLine =
1216 lineInfo.getLocation(lastOffset).lineNumber; 1332 lineInfo.getLocation(lastOffset).lineNumber;
1217 var currentLine = 1333 var currentLine =
1218 lineInfo.getLocation(currentOffset).lineNumber; 1334 lineInfo.getLocation(currentOffset).lineNumber;
1219 return currentLine - lastLine; 1335 return currentLine - lastLine;
1220 } 1336 }
1221 1337
1222 String toString() => writer.toString(); 1338 String toString() => writer.toString();
1223 1339
1224 } 1340 }
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698