Skip to content

Commit fd80dde

Browse files
authored
Fix; detect wrong title level for changes & handle duplicate sections (#5)
1 parent 2a37f40 commit fd80dde

17 files changed

+264
-54
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88

9+
## 0.2.2 - 2022-08-01
10+
11+
### Fixed
12+
13+
- Raise error on wrong title level for changes.
14+
- Raise error on duplicate version sections.
15+
- Merge duplicate changes sections.
16+
17+
918
## 0.2.1 - 2022-08-01
1019

1120
### Fixed

ocdc/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.2.1"
1+
__version__ = "0.2.2"

ocdc/ast.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ class Changes(BaseModel):
2222
items: List[ListItem]
2323
footer: str = ""
2424

25+
def merge(self, other: "Changes") -> None:
26+
self.items.extend(other.items)
27+
self.footer += "\n" + other.footer
28+
2529

2630
class Version(BaseModel):
2731
number: str

ocdc/parser.py

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
from contextlib import contextmanager
12
from dataclasses import dataclass
23
from enum import Enum, auto
3-
from typing import Any, List, Optional, Set, Tuple
4+
from typing import Any, Dict, List, Optional, Set, Tuple
45

56
from . import ast
67
from .renderer import INDENT
@@ -12,15 +13,35 @@ def parse(text: str) -> ast.Changelog:
1213

1314

1415
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\nHint: {hint}"
28+
super().__init__(msg)
1829

19-
arrows = " " * col_start + "^" * (col_end - col_start)
20-
details = (f" {prev}\n" if prev else "") + f" {line}\n {arrows}"
2130

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}"
2445

2546

2647
class TokenType(Enum):
@@ -35,7 +56,6 @@ class TokenType(Enum):
3556
@dataclass
3657
class Token:
3758
typ: TokenType
38-
pos: int
3959
row: int
4060
col: int
4161
text: str
@@ -70,7 +90,7 @@ def peek(self, offset: int = 0) -> str:
7090

7191
def add_token(self, typ: TokenType, text: str = "", parsed: Any = None):
7292
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)
7494
self.tokens.append(token)
7595

7696
def __call__(self) -> List[Token]:
@@ -114,6 +134,17 @@ def __init__(self, source: str, tokens: List[Token]):
114134
def has_more(self) -> bool:
115135
return self.peek().typ != TokenType.EOF
116136

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+
117148
def advance(self, n: int = 1):
118149
if self.has_more:
119150
self.current += n
@@ -132,6 +163,12 @@ def match(self, types: Set[TokenType]) -> bool:
132163
return True
133164
return False
134165

166+
def match_many(self, typ: TokenType) -> int:
167+
n = 0
168+
while self.match({typ}):
169+
n += 1
170+
return n
171+
135172
def expect(self, *types: TokenType) -> bool:
136173
n = len(types)
137174
if self.current + n < len(self.tokens):
@@ -141,15 +178,15 @@ def expect(self, *types: TokenType) -> bool:
141178
return False
142179

143180
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 = "",
145187
) -> ParseError:
146188
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)
153190

154191
def __call__(self) -> ast.Changelog:
155192
return changelog(self)
@@ -167,8 +204,19 @@ def changelog(p: Parser) -> ast.Changelog:
167204
while p.match({TokenType.FOOTER_BEGIN}):
168205
c.intro += "\n" + text(p)
169206

207+
seen_version_titles: Dict[str, Token] = {}
170208
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)
172220

173221
if p.has_more:
174222
raise p.error("Unprocessable text", forward=len(p.peek().text))
@@ -183,8 +231,9 @@ def text(p: Parser) -> str:
183231
return text.strip()
184232

185233

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
188237
try:
189238
number, date = title.split(" - ", 1)
190239
except ValueError:
@@ -196,9 +245,28 @@ def version(p: Parser) -> ast.Version:
196245

197246
while p.expect(TokenType.HASH, TokenType.HASH, TokenType.HASH, TokenType.TEXT):
198247
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+
200258

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
202270

203271

204272
def changes(p: Parser) -> Tuple[ast.ChangeType, ast.Changes]:
@@ -207,8 +275,9 @@ def changes(p: Parser) -> Tuple[ast.ChangeType, ast.Changes]:
207275
try:
208276
type_ = getattr(ast.ChangeType, title)
209277
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
212281

213282
skip_newlines(p)
214283

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "ocdc"
3-
version = "0.2.1"
3+
version = "0.2.2"
44
description = 'A changelog formatter for "people", neat freaks, and sloppy typists.'
55
authors = ["Matteo De Wint <[email protected]>"]
66
packages = [{ include = "ocdc" }]

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.2.1
2+
current_version = 0.2.2
33
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
44
serialize =
55
{major}.{minor}.{patch}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"title": "Changelog",
3+
"intro": "All notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).",
4+
"versions": [
5+
{
6+
"number": "0.1.0",
7+
"date": "2022-07-31",
8+
"changes": {
9+
"Added": {
10+
"items": [
11+
{
12+
"text": "Something in [one]."
13+
},
14+
{
15+
"text": "Something in [two]."
16+
}
17+
],
18+
"footer": "[one]: http://example.com/one\n[two]: http://example.com/two"
19+
},
20+
"Fixed": {
21+
"items": [
22+
{
23+
"text": "A bug."
24+
}
25+
]
26+
}
27+
}
28+
}
29+
]
30+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## 0.1.0 - 2022-07-31
9+
10+
### Added
11+
12+
- Something in [one].
13+
14+
[one]: http://example.com/one
15+
16+
### Fixed
17+
18+
- A bug.
19+
20+
### Added
21+
22+
- Something in [two].
23+
24+
[two]: http://example.com/two
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
9+
## 0.1.0 - 2022-07-31
10+
11+
### Added
12+
13+
- Something in [one].
14+
- Something in [two].
15+
16+
[one]: http://example.com/one
17+
[two]: http://example.com/two
18+
19+
### Fixed
20+
21+
- A bug.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## 0.1.0 - 2022-08-02
9+
10+
### Added
11+
12+
- Something else.
13+
14+
## 0.2.0 - 2022-08-01
15+
16+
### Added
17+
18+
- Something.
19+
20+
## 0.1.0 - 2022-07-31
21+
22+
### Added
23+
24+
- Initial version.

0 commit comments

Comments
 (0)