1 files changed,
143 insertions(+),
246 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-04 14:36:36 +0300
Authored at:
2026-08-04 13:26:56 +0300
Change ID:
zoyqtnzpwslwspvwkvprzsmolkwsyqss
Parent:
d216565
M
journal/parser/parser.go
··· 45 45 return f 46 46 } 47 47 48 -func isDirectiveKeyword(t token.Type) bool { 49 - switch t { 50 - case token.COMMENTKW, token.ACCOUNT, token.COMMODITY, token.INCLUDE, 51 - token.ALIAS, token.PAYEE, token.TAG, token.APPLY, token.END, 52 - token.YEAR, token.DECIMALMARK, token.D, token.P, token.N, token.C: 53 - return true 54 - } 55 - return false 56 -} 57 - 58 48 func (p *Parser) parseEntry() ast.Entry { 59 49 if p.got(token.BANG) || p.got(token.AT) { 60 50 if isDirectiveKeyword(p.peek.Type) { 61 51 p.advance() // consume prefix 62 52 } 63 53 } 54 + 64 55 switch p.cur.Type { 65 56 case token.ILLEGAL: 66 57 p.errorf("illegal character %q", p.cur.Literal) ··· 138 129 // optional status 139 130 tx.Status = p.parseStatus() 140 131 141 - // optional code 142 - if p.got(token.LPAREN) { 143 - cs := p.cur.Span 144 - p.advance() 145 - var code strings.Builder 146 - for p.cur.Type != token.RPAREN { 147 - _, _ = code.WriteString(p.cur.Literal) 148 - p.advance() 149 - } 150 - rp := p.cur.Span 151 - p.advance() 152 - tx.Code = &ast.Code{ 153 - Value: code.String(), 154 - Span: token.Span{Start: cs.Start, End: rp.End}, 155 - } 156 - p.skipWhitespace() 157 - } else if p.got(token.TEXT) { 158 - // the lexer emits "(CODE)" as a single TEXT token; split it here 132 + // optional code - the lexer emits "(CODE)" as a single TEXT token; split it here 133 + if p.got(token.TEXT) { 159 134 if lit := p.cur.Literal; len(lit) >= 2 && lit[0] == '(' && lit[len(lit)-1] == ')' { 160 135 tx.Code = &ast.Code{Value: lit[1 : len(lit)-1], Span: p.cur.Span} 161 136 p.advance() ··· 168 143 tx.Payee = p.parsePayee() 169 144 170 145 // check for | separator 171 - if p.got(token.WHITESPACE) { 172 - p.skipWhitespace() 173 - } 146 + p.skipWhitespace() 174 147 175 148 if p.got(token.PIPE) { 176 149 p.advance() ··· 186 159 tx.Comment = p.parseOptInlineComment() 187 160 p.expectNewline() 188 161 189 - // header comments — indented ; lines before first posting 190 - for p.got(token.INDENT) && p.willGet(token.SEMICOLON) { 191 - p.advance() // consume indent 192 - c := p.parseComment() 193 - tx.HeaderComments = append(tx.HeaderComments, c) 194 - } 195 - 196 - // postings 197 - for p.got(token.INDENT) { 198 - if p := p.parsePosting(); p != nil { 199 - tx.Postings = append(tx.Postings, p) 200 - } 201 - } 162 + tx.HeaderComments, tx.Postings = p.parseHeaderCommentsAndPostings() 202 163 203 164 tx.Span = p.span(s) 204 165 return tx ··· 222 183 223 184 // keep spaces/tags between text tokens; stop before trailing whitespace 224 185 var name strings.Builder 225 - for p.got(token.TEXT) || p.got(token.INT) || p.got(token.DECIMAL) || p.got(token.COMMODITYMARK) || (p.got(token.WHITESPACE) && (p.willGet(token.TEXT) || p.willGet(token.INT) || p.willGet(token.DECIMAL) || p.willGet(token.COMMODITYMARK))) { 186 + for payeeWord(p.cur.Type) || (payeeWord(p.peek.Type) && p.got(token.WHITESPACE)) { 226 187 _, _ = name.WriteString(p.cur.Literal) 227 188 p.advance() 228 189 } 229 190 return &ast.Payee{Name: unquote(name.String()), Span: p.span(s)} 191 +} 192 + 193 +func payeeWord(t token.Type) bool { 194 + switch t { 195 + case token.TEXT, token.INT, token.DECIMAL, token.COMMODITYMARK: 196 + return true 197 + } 198 + return false 230 199 } 231 200 232 201 func (p *Parser) parsePeriodicTransaction() *ast.PeriodicTransaction { ··· 245 214 comment := p.parseOptInlineComment() 246 215 p.expectNewline() 247 216 248 - // header comment 249 - for p.got(token.INDENT) && p.willGet(token.SEMICOLON) { 250 - p.advance() 251 - pt.HeaderComments = append(pt.HeaderComments, p.parseComment()) 252 - } 253 - 254 - // postings 255 - for p.got(token.INDENT) { 256 - if posting := p.parsePosting(); posting != nil { 257 - pt.Postings = append(pt.Postings, posting) 258 - } 259 - } 217 + pt.HeaderComments, pt.Postings = p.parseHeaderCommentsAndPostings() 260 218 261 219 pt.Span = p.span(s) 262 220 pt.Comment = comment ··· 277 235 at.Comment = p.parseOptInlineComment() 278 236 p.expectNewline() 279 237 280 - // header comments 238 + at.HeaderComments, at.Postings = p.parseHeaderCommentsAndPostings() 239 + 240 + at.Span = p.span(s) 241 + return at 242 +} 243 + 244 +func (p *Parser) parseHeaderCommentsAndPostings() (comments []*ast.Comment, postings []*ast.Posting) { 281 245 for p.got(token.INDENT) && p.willGet(token.SEMICOLON) { 282 - p.advance() 283 - at.HeaderComments = append(at.HeaderComments, p.parseComment()) 246 + p.advance() // consume indent 247 + comments = append(comments, p.parseComment()) 284 248 } 285 249 286 - // postings 287 250 for p.got(token.INDENT) { 288 - if p := p.parsePosting(); p != nil { 289 - at.Postings = append(at.Postings, p) 251 + if posting := p.parsePosting(); posting != nil { 252 + postings = append(postings, posting) 290 253 } 291 254 } 292 255 293 - at.Span = p.span(s) 294 - return at 256 + return comments, postings 295 257 } 296 258 297 259 func (p *Parser) parsePeriod() ast.Period { ··· 363 325 364 326 func (p *Parser) parseComment() *ast.Comment { 365 327 s := p.cur.Span 366 - marker := p.cur.Literal[0] 367 - p.advance() 368 - p.skipWhitespace() 369 - 370 - var text string 371 - if p.got(token.TEXT) { 372 - text = p.cur.Literal 373 - p.advance() 374 - } 375 - 328 + c := p.parseCommentRest(s) 376 329 p.expectNewline() 377 - 378 - return &ast.Comment{ 379 - Marker: marker, 380 - Text: text, 381 - Span: p.span(s), 382 - } 330 + c.Span = p.span(s) // comment spans its line through the newline 331 + return c 383 332 } 384 333 385 334 func (p *Parser) parseAccountDirective() *ast.AccountDirective { ··· 418 367 switch p.cur.Type { 419 368 case token.COMMODITYMARK, token.TEXT, token.STRING: 420 369 cs := p.cur.Span 421 - commodity = p.cur.Literal 422 - if p.got(token.STRING) { 423 - commodity = unquote(commodity) 424 - } 370 + commodity = unquote(p.cur.Literal) 425 371 p.advance() 426 372 commoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 427 373 hadSpace := p.got(token.WHITESPACE) ··· 490 436 p.errorf("expected file path, got %s", p.cur.Type) 491 437 } 492 438 493 - p.skipWhitespace() 494 439 id.Comment = p.parseOptInlineComment() 495 440 p.expectNewline() 496 441 id.Span = p.span(s) ··· 507 452 p.expect(token.EQ) 508 453 p.skipWhitespace() 509 454 alias.To = p.parseAccount() 510 - p.skipWhitespace() 511 455 alias.Comment = p.parseOptInlineComment() 512 456 p.expectNewline() 513 457 alias.Span = p.span(s) ··· 540 484 p.skipWhitespace() 541 485 542 486 name := "" 543 - if p.got(token.TEXT) || p.got(token.COMMODITYMARK) { 544 - name = p.cur.Literal 545 - p.advance() 546 - } else if p.got(token.STRING) { 487 + if p.got(token.TEXT) || p.got(token.COMMODITYMARK) || p.got(token.STRING) { 547 488 name = unquote(p.cur.Literal) 548 489 p.advance() 549 490 } ··· 572 513 p.errorf("expected year, got %s", p.cur.Type) 573 514 } 574 515 575 - p.skipWhitespace() 576 516 year.Comment = p.parseOptInlineComment() 577 517 p.expectNewline() 578 518 year.Span = p.span(s) ··· 594 534 p.advance() 595 535 } 596 536 597 - p.skipWhitespace() 598 537 mark.Comment = p.parseOptInlineComment() 599 538 p.expectNewline() 600 539 mark.Span = p.span(s) ··· 607 546 p.expect(token.D) 608 547 p.skipWhitespace() 609 548 com.Amount = *p.parseAmount() 610 - p.skipWhitespace() 611 549 com.Comment = p.parseOptInlineComment() 612 550 p.expectNewline() 613 551 com.Span = p.span(s) ··· 637 575 } 638 576 } 639 577 640 - p.skipWhitespace() 641 578 cd.Comment = p.parseOptInlineComment() 642 579 p.expectNewline() 643 580 cd.Span = p.span(s) ··· 654 591 id.Text = p.cur.Literal 655 592 p.advance() 656 593 } 657 - p.skipWhitespace() 658 594 id.Comment = p.parseOptInlineComment() 659 595 660 596 p.expectNewline() ··· 682 618 683 619 mp.Amount = *p.parseAmount() 684 620 685 - p.skipWhitespace() 686 621 mp.Comment = p.parseOptInlineComment() 687 622 688 623 p.expectNewline() ··· 809 744 st := ast.Status{} 810 745 switch p.cur.Type { 811 746 case token.STAR: 812 - p.advance() 813 - p.skipWhitespace() 814 747 st.Value = ast.StatusCleared 815 748 case token.BANG: 749 + st.Value = ast.StatusPending 750 + } 751 + if st.Value != ast.StatusNone { 816 752 p.advance() 817 753 p.skipWhitespace() 818 - st.Value = ast.StatusPending 819 - default: 820 - st.Value = ast.StatusNone 821 754 } 822 755 st.Span = p.span(s) 823 756 return st ··· 827 760 switch p.cur.Type { 828 761 default: 829 762 return false 830 - case token.COMMODITYMARK, token.STRING, token.INT, token.DECIMAL, token.MINUS, token.PLUS, token.PARENEXPR: 763 + case token.COMMODITYMARK, token.STRING, token.INT, token.DECIMAL, token.MINUS, token.PLUS, token.PARENEXPR, token.STAR: 831 764 return true 832 765 } 833 766 } ··· 844 777 amt.Span = p.span(s) 845 778 }() 846 779 780 + p.parseAmountSign(amt) 781 + p.skipWhitespace() 782 + 847 783 // commodity before quantity: $10.00, eur 10.00 848 784 if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) { 849 785 cs := p.cur.Span ··· 855 791 amt.HasSpace = true 856 792 p.skipWhitespace() 857 793 } 858 - switch p.cur.Type { 859 - case token.MINUS: 860 - amt.IsNegative = true 861 - p.advance() 862 - case token.PLUS: 863 - p.advance() 864 - } 865 - p.skipWhitespace() 866 - p.parseQuantityInto(amt) 867 - } else { 868 - // optional sign 869 - switch p.cur.Type { 870 - case token.MINUS: 871 - amt.IsNegative = true 872 - p.advance() 873 - case token.PLUS: 874 - p.advance() 875 - } 876 - p.skipWhitespace() 794 + } 877 795 878 - // commodity before quantity: -$120, -eur 120: 879 - if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) { 880 - cs := p.cur.Span 881 - amt.Commodity = unquote(p.cur.Literal) 882 - amt.CommodityPos = ast.CommodityBefore 883 - p.advance() 884 - amt.CommoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 885 - if p.got(token.WHITESPACE) { 886 - amt.HasSpace = true 887 - p.skipWhitespace() 888 - } 889 - } 796 + // optional sign after commodity: $ -10 797 + p.parseAmountSign(amt) 798 + p.skipWhitespace() 890 799 891 - p.parseQuantityInto(amt) 800 + p.parseQuantityInto(amt) 892 801 893 - // commodity after quantity: 10.00 UAH, 10.00 "EUR" (only if not set) 894 - if amt.Commodity == "" { 895 - switch p.cur.Type { 896 - case token.WHITESPACE: 897 - p.skipWhitespace() 898 - if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) { 899 - cs := p.cur.Span 900 - amt.HasSpace = true 901 - amt.Commodity = unquote(p.cur.Literal) 902 - amt.CommodityPos = ast.CommodityAfter 903 - p.advance() 904 - amt.CommoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 905 - } 906 - case token.COMMODITYMARK, token.TEXT, token.STRING: 802 + // commodity after quantity: 10.00 UAH, 10.00 "EUR" (only if not set) 803 + if amt.Commodity == "" { 804 + switch p.cur.Type { 805 + case token.WHITESPACE: 806 + p.skipWhitespace() 807 + if p.got(token.COMMODITYMARK) || p.got(token.TEXT) || p.got(token.STRING) { 907 808 cs := p.cur.Span 809 + amt.HasSpace = true 908 810 amt.Commodity = unquote(p.cur.Literal) 909 811 amt.CommodityPos = ast.CommodityAfter 910 812 p.advance() 911 813 amt.CommoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 912 814 } 815 + case token.COMMODITYMARK, token.TEXT, token.STRING: 816 + cs := p.cur.Span 817 + amt.Commodity = unquote(p.cur.Literal) 818 + amt.CommodityPos = ast.CommodityAfter 819 + p.advance() 820 + amt.CommoditySpan = token.Span{Start: cs.Start, End: p.cur.Span.Start} 913 821 } 914 822 } 915 823 916 824 return amt 825 +} 826 + 827 +// parseAmountSign consumes an optional leading +/- into IsNegative. 828 +func (p *Parser) parseAmountSign(amt *ast.Amount) { 829 + switch p.cur.Type { 830 + case token.MINUS: 831 + amt.IsNegative = true 832 + p.advance() 833 + case token.PLUS: 834 + p.advance() 835 + } 917 836 } 918 837 919 838 func (p *Parser) parseAmountWithOptExpr() *ast.Amount { ··· 933 852 QuantityFmt: ast.QuantityFormat{Decimal: '.'}, 934 853 } 935 854 if len(lit) >= 2 && lit[0] == '(' && lit[len(lit)-1] == ')' { 936 - inner := lit[1 : len(lit)-1] 937 - i := 0 938 - for i < len(inner) && (inner[i] == ' ' || inner[i] == '\t') { 939 - i++ 940 - } 941 - j := len(inner) 942 - for j > i && (inner[j-1] == ' ' || inner[j-1] == '\t') { 943 - j-- 944 - } 945 - amt.Expr = inner[i:j] 855 + amt.Expr = strings.Trim(lit[1:len(lit)-1], " \t") 946 856 } 947 857 amt.Span = p.cur.Span 948 858 p.advance() ··· 1000 910 // optional amount - after two spaces 1001 911 if p.got(token.WHITESPACE) { 1002 912 p.skipWhitespace() 1003 - if p.isAmountStart() || p.got(token.STAR) { 913 + if p.isAmountStart() { 1004 914 posting.Amount = p.parseAmountWithOptExpr() 1005 915 } 1006 916 } 1007 917 1008 918 // optional cost '@' or '@@' 1009 - if p.got(token.WHITESPACE) { 1010 - p.skipWhitespace() 1011 - } 919 + p.skipWhitespace() 1012 920 if p.got(token.AT) || p.got(token.ATAT) { 1013 921 posting.Cost = p.parseCost() 1014 922 } 1015 923 1016 924 // optional balance assertion or assignment 1017 - if p.got(token.WHITESPACE) { 1018 - p.skipWhitespace() 1019 - } 925 + p.skipWhitespace() 1020 926 if p.got(token.COLON) && p.willGet(token.EQ) { 1021 927 p.advance() // consume ':' of ':=' 1022 928 posting.Balance = p.parseBalanceAssertion() ··· 1139 1045 return ast.Date{Span: p.span(s)} 1140 1046 } 1141 1047 1142 - sep := byte(0) 1143 - lit := tok.Literal 1144 - for i := 0; i < len(lit); i++ { 1145 - if lit[i] == '/' || lit[i] == '-' || lit[i] == '.' { 1146 - sep = lit[i] 1147 - break 1148 - } 1149 - } 1150 - if sep == 0 { 1151 - p.errorf("invalid date format: %q", lit) 1048 + year, month, day, sep, err := parseDateLiteral(tok.Literal) 1049 + if err != nil { 1050 + p.errorf("%v", err) 1152 1051 return ast.Date{Span: p.span(s)} 1153 1052 } 1154 - 1155 - parts := strings.Split(lit, string(sep)) 1156 - 1157 - // M/D or MM/DD (year inferred) 1158 - if len(parts) == 2 { 1159 - month, err := strconv.Atoi(parts[0]) 1160 - day, err2 := strconv.Atoi(parts[1]) 1161 - if err != nil || err2 != nil { 1162 - p.errorf("invalid date literal: %q", lit) 1163 - return ast.Date{Span: p.span(s)} 1164 - } 1165 - if month < 1 || month > 12 { 1166 - p.errorf("invalid month %d in %q", month, lit) 1167 - return ast.Date{Span: p.span(s)} 1168 - } 1169 - if day < 1 || day > 31 { 1170 - p.errorf("invalid day %d in %q", day, lit) 1171 - return ast.Date{Span: p.span(s)} 1172 - } 1173 - return ast.Date{Year: p.defaultYear, Month: month, Day: day, Sep: sep, Span: p.span(s)} 1174 - } 1175 - 1176 - if len(parts) != 3 { 1177 - p.errorf("invalid date format: %q", lit) 1178 - return ast.Date{Span: p.span(s)} 1179 - } 1180 - 1181 - year, err := strconv.Atoi(parts[0]) 1182 - month, err2 := strconv.Atoi(parts[1]) 1183 - day, err3 := strconv.Atoi(parts[2]) 1184 - if err != nil || err2 != nil || err3 != nil { 1185 - p.errorf("invalid date literal: %q", lit) 1186 - return ast.Date{Span: p.span(s)} 1187 - } 1188 - if month < 1 || month > 12 { 1189 - p.errorf("invalid month %d in %q", month, lit) 1190 - return ast.Date{Span: p.span(s)} 1191 - } 1192 - if day < 1 || day > 31 { 1193 - p.errorf("invalid day %d in %q", day, lit) 1194 - return ast.Date{Span: p.span(s)} 1053 + if year == 0 { 1054 + year = p.defaultYear 1195 1055 } 1196 1056 1197 - return ast.Date{ 1198 - Year: year, 1199 - Month: month, 1200 - Day: day, 1201 - Sep: sep, 1202 - Span: p.span(s), 1203 - } 1057 + return ast.Date{Year: year, Month: month, Day: day, Sep: sep, Span: p.span(s)} 1204 1058 } 1205 1059 1206 1060 func (p *Parser) parseOptInlineComment() *ast.Comment { 1207 1061 p.skipWhitespace() 1208 - if p.cur.Type != token.SEMICOLON { 1062 + if !p.got(token.SEMICOLON) { 1209 1063 return nil 1210 1064 } 1065 + return p.parseCommentRest(p.cur.Span) 1066 +} 1211 1067 1212 - s := p.cur.Span 1068 +// parseCommentRest consumes a comment marker at p.cur, then optional text; 1069 +// s anchors the span at the marker's start. 1070 +func (p *Parser) parseCommentRest(s token.Span) *ast.Comment { 1213 1071 marker := p.cur.Literal[0] 1214 - p.advance() // consume marker 1072 + p.advance() 1215 1073 p.skipWhitespace() 1216 1074 1217 1075 text := "" ··· 1330 1188 }) 1331 1189 } 1332 1190 1191 +func isDirectiveKeyword(t token.Type) bool { 1192 + switch t { 1193 + case token.COMMENTKW, token.ACCOUNT, token.COMMODITY, token.INCLUDE, 1194 + token.ALIAS, token.PAYEE, token.TAG, token.APPLY, token.END, 1195 + token.YEAR, token.DECIMALMARK, token.D, token.P, token.N, token.C: 1196 + return true 1197 + } 1198 + return false 1199 +} 1200 + 1333 1201 func (p *Parser) sync() { 1334 1202 for { 1335 1203 switch p.cur.Type { ··· 1337 1205 return 1338 1206 case token.NEWLINE: 1339 1207 p.advance() 1340 - switch p.cur.Type { 1341 - case token.DATE, token.ACCOUNT, token.COMMODITY, 1342 - token.INCLUDE, token.ALIAS, token.PAYEE, 1343 - token.TAG, token.YEAR, token.D, token.P, 1344 - token.APPLY, token.END, token.COMMENTKW, 1345 - token.DECIMALMARK, token.TILDE, token.N, token.EQ: 1208 + t := p.cur.Type 1209 + if isDirectiveKeyword(t) || t == token.DATE || t == token.TILDE || t == token.EQ { 1346 1210 return 1347 1211 } 1348 1212 default: ··· 1417 1281 return ast.QuantityFormat{Decimal: dec, Thousands: thou, Precision: prec} 1418 1282 } 1419 1283 1284 +// parseSimpleDate parses full YYYY/MM/DD date literal embedded in free text. 1420 1285 func parseSimpleDate(s string) ast.Date { 1421 - if len(s) < 8 { 1286 + year, month, day, sep, err := parseDateLiteral(s) 1287 + if err != nil { 1422 1288 return ast.Date{} 1423 1289 } 1424 - sep := byte('-') 1425 - if strings.Contains(s, "/") { 1426 - sep = byte('/') 1427 - } else if strings.Contains(s, ".") { 1428 - sep = byte('.') 1290 + return ast.Date{Year: year, Month: month, Day: day, Sep: sep} 1291 +} 1292 + 1293 +// parseDateLiteral parses and validates a date literal. 1294 +func parseDateLiteral(lit string) (year, month, day int, sep byte, err error) { 1295 + sep = dateSeparator(lit) 1296 + if sep == 0 { 1297 + return 0, 0, 0, 0, fmt.Errorf("invalid date format: %q", lit) 1429 1298 } 1430 - parts := strings.Split(s, string(sep)) 1431 - if len(parts) != 3 { 1432 - return ast.Date{} 1299 + 1300 + parts := strings.Split(lit, string(sep)) 1301 + if len(parts) != 2 && len(parts) != 3 { 1302 + return 0, 0, 0, 0, fmt.Errorf("invalid date format: %q", lit) 1433 1303 } 1434 - year, _ := strconv.Atoi(parts[0]) 1435 - month, _ := strconv.Atoi(parts[1]) 1436 - day, _ := strconv.Atoi(parts[2]) 1437 - return ast.Date{Year: year, Month: month, Day: day, Sep: sep} 1304 + 1305 + nums := make([]int, len(parts)) 1306 + for i, part := range parts { 1307 + if nums[i], err = strconv.Atoi(part); err != nil { 1308 + return 0, 0, 0, 0, fmt.Errorf("invalid date literal: %q", lit) 1309 + } 1310 + } 1311 + 1312 + month = nums[len(parts)-2] 1313 + if month < 1 || month > 12 { 1314 + return 0, 0, 0, 0, fmt.Errorf("invalid month %d in %q", month, lit) 1315 + } 1316 + 1317 + day = nums[len(parts)-1] 1318 + if day < 1 || day > 31 { 1319 + return 0, 0, 0, 0, fmt.Errorf("invalid day %d in %q", day, lit) 1320 + } 1321 + 1322 + if len(parts) == 2 { 1323 + return 0, month, day, sep, nil 1324 + } 1325 + return nums[0], month, day, sep, nil 1326 +} 1327 + 1328 +func dateSeparator(lit string) byte { 1329 + for i := 0; i < len(lit); i++ { 1330 + if lit[i] == '/' || lit[i] == '-' || lit[i] == '.' { 1331 + return lit[i] 1332 + } 1333 + } 1334 + return 0 1438 1335 }