1
+ from contextlib import contextmanager
1
2
from dataclasses import dataclass
2
3
from enum import Enum , auto
3
- from typing import Any , List , Optional , Set , Tuple
4
+ from typing import Any , Dict , List , Optional , Set , Tuple
4
5
5
6
from . import ast
6
7
from .renderer import INDENT
@@ -12,15 +13,35 @@ def parse(text: str) -> ast.Changelog:
12
13
13
14
14
15
class ParseError (Exception ):
15
- def __init__ (self , msg : str , source : str , row : int , col_start : int , col_end : int ):
16
- lines = source .splitlines ()
17
- prev , line = lines [row - 1 : row + 1 ] if row > 0 else ("" , lines [row ])
16
+ def __init__ (
17
+ self ,
18
+ msg : str ,
19
+ source : str ,
20
+ token : "Token" ,
21
+ back : int = 0 ,
22
+ forward : int = 0 ,
23
+ hint : str = "" ,
24
+ ):
25
+ msg = f"{ msg } { annotate (source , token , back , forward )} "
26
+ if hint :
27
+ msg += f"\n \n Hint: { hint } "
28
+ super ().__init__ (msg )
18
29
19
- arrows = " " * col_start + "^" * (col_end - col_start )
20
- details = (f" { prev } \n " if prev else "" ) + f" { line } \n { arrows } "
21
30
22
- msg = f"{ msg } (line { row + 1 } , column { col_start + 1 } ):\n \n { details } "
23
- super ().__init__ (msg )
31
+ def annotate (source : str , token : "Token" , back : int = 0 , forward : int = 0 ) -> str :
32
+ lines = source .splitlines ()
33
+ row = min (token .row , len (lines ) - 1 )
34
+ prev , line = lines [row - 1 : row + 1 ] if row > 0 else ("" , lines [row ])
35
+
36
+ col_start = col_end = token .col
37
+ if back :
38
+ col_start -= back
39
+ if forward :
40
+ col_end += forward
41
+
42
+ arrows = " " * col_start + "^" * (col_end - col_start )
43
+ details = (f" { prev } \n " if prev else "" ) + f" { line } \n { arrows } "
44
+ return f"at line { row + 1 } , column { col_start + 1 } :\n \n { details } "
24
45
25
46
26
47
class TokenType (Enum ):
@@ -35,7 +56,6 @@ class TokenType(Enum):
35
56
@dataclass
36
57
class Token :
37
58
typ : TokenType
38
- pos : int
39
59
row : int
40
60
col : int
41
61
text : str
@@ -70,7 +90,7 @@ def peek(self, offset: int = 0) -> str:
70
90
71
91
def add_token (self , typ : TokenType , text : str = "" , parsed : Any = None ):
72
92
col = self .col - len (self .matched )
73
- token = Token (typ , self .start , self . row , col , text or self .matched , parsed )
93
+ token = Token (typ , self .row , col , text or self .matched , parsed )
74
94
self .tokens .append (token )
75
95
76
96
def __call__ (self ) -> List [Token ]:
@@ -114,6 +134,17 @@ def __init__(self, source: str, tokens: List[Token]):
114
134
def has_more (self ) -> bool :
115
135
return self .peek ().typ != TokenType .EOF
116
136
137
+ class Rollback (Exception ):
138
+ pass
139
+
140
+ @contextmanager
141
+ def checkpoint (self ):
142
+ saved = self .current
143
+ try :
144
+ yield
145
+ except self .Rollback :
146
+ self .current = saved
147
+
117
148
def advance (self , n : int = 1 ):
118
149
if self .has_more :
119
150
self .current += n
@@ -132,6 +163,12 @@ def match(self, types: Set[TokenType]) -> bool:
132
163
return True
133
164
return False
134
165
166
+ def match_many (self , typ : TokenType ) -> int :
167
+ n = 0
168
+ while self .match ({typ }):
169
+ n += 1
170
+ return n
171
+
135
172
def expect (self , * types : TokenType ) -> bool :
136
173
n = len (types )
137
174
if self .current + n < len (self .tokens ):
@@ -141,15 +178,15 @@ def expect(self, *types: TokenType) -> bool:
141
178
return False
142
179
143
180
def error (
144
- self , msg : str , token : Optional [Token ] = None , back : int = 0 , forward : int = 0
181
+ self ,
182
+ msg : str ,
183
+ token : Optional [Token ] = None ,
184
+ back : int = 0 ,
185
+ forward : int = 0 ,
186
+ hint : str = "" ,
145
187
) -> ParseError :
146
188
token = token or self .peek ()
147
- col_start = col_end = token .col
148
- if back :
149
- col_start -= back
150
- if forward :
151
- col_end += forward
152
- return ParseError (msg , self .source , token .row , col_start , col_end )
189
+ return ParseError (msg , self .source , token , back , forward , hint )
153
190
154
191
def __call__ (self ) -> ast .Changelog :
155
192
return changelog (self )
@@ -167,8 +204,19 @@ def changelog(p: Parser) -> ast.Changelog:
167
204
while p .match ({TokenType .FOOTER_BEGIN }):
168
205
c .intro += "\n " + text (p )
169
206
207
+ seen_version_titles : Dict [str , Token ] = {}
170
208
while p .expect (TokenType .HASH , TokenType .HASH , TokenType .TEXT ):
171
- c .versions .append (version (p ))
209
+ v , title_token = version (p )
210
+
211
+ duplicate_title_token = seen_version_titles .get (v .number )
212
+ if duplicate_title_token :
213
+ forward = len (v .number )
214
+ at = annotate (p .source , title_token , forward = forward )
215
+ msg , hint = "Duplicate version" , f"Version was first used { at } "
216
+ raise p .error (msg , duplicate_title_token , forward = forward , hint = hint )
217
+
218
+ seen_version_titles [v .number ] = title_token
219
+ c .versions .append (v )
172
220
173
221
if p .has_more :
174
222
raise p .error ("Unprocessable text" , forward = len (p .peek ().text ))
@@ -183,8 +231,9 @@ def text(p: Parser) -> str:
183
231
return text .strip ()
184
232
185
233
186
- def version (p : Parser ) -> ast .Version :
187
- title = p .peek (1 ).text
234
+ def version (p : Parser ) -> Tuple [ast .Version , Token ]:
235
+ title_token = p .peek (1 )
236
+ title = title_token .text
188
237
try :
189
238
number , date = title .split (" - " , 1 )
190
239
except ValueError :
@@ -196,9 +245,28 @@ def version(p: Parser) -> ast.Version:
196
245
197
246
while p .expect (TokenType .HASH , TokenType .HASH , TokenType .HASH , TokenType .TEXT ):
198
247
type_ , changes_ = changes (p )
199
- v .changes [type_ .value ] = changes_
248
+ key = type_ .value
249
+ if key in v .changes :
250
+ v .changes [key ].merge (changes_ )
251
+ else :
252
+ v .changes [key ] = changes_
253
+
254
+ detect_wrong_title_level (p , 3 )
255
+
256
+ return v , title_token
257
+
200
258
201
- return v
259
+ def detect_wrong_title_level (p : Parser , expected : int ) -> None :
260
+ with p .checkpoint ():
261
+ level = p .match_many (TokenType .HASH )
262
+ if level and level != expected :
263
+ token = p .peek (1 )
264
+ p .match ({TokenType .TEXT })
265
+ skip_newlines (p )
266
+ if p .match ({TokenType .DASH }):
267
+ msg = f"Expected a title of H{ expected } , but found H{ level } "
268
+ raise p .error (msg , token , back = level )
269
+ raise p .Rollback
202
270
203
271
204
272
def changes (p : Parser ) -> Tuple [ast .ChangeType , ast .Changes ]:
@@ -207,8 +275,9 @@ def changes(p: Parser) -> Tuple[ast.ChangeType, ast.Changes]:
207
275
try :
208
276
type_ = getattr (ast .ChangeType , title )
209
277
except AttributeError :
210
- err = p .error ("Unexpected title for changes" , token , forward = len (title ))
211
- raise err from None
278
+ msg = "Unexpected title for changes"
279
+ hint = "Choose from " + ", " .join (f'"{ t .value } "' for t in ast .ChangeType ) + "."
280
+ raise p .error (msg , token , forward = len (title ), hint = hint ) from None
212
281
213
282
skip_newlines (p )
214
283
0 commit comments