diff --git a/yaclog/changelog.py b/yaclog/changelog.py index 18daa75..3dbb23d 100644 --- a/yaclog/changelog.py +++ b/yaclog/changelog.py @@ -43,9 +43,13 @@ class VersionEntry: self.date: Optional[datetime.date] = None self.tags: List[str] = [] self.link: str = '' + self.line_no = -1 def __str__(self) -> str: - segments = ['##', self.name] + if self.link: + segments = [f'[{self.name}]'] + else: + segments = [self.name] if self.date: segments += ['-', self.date.isoformat()] @@ -62,75 +66,126 @@ class Changelog: self.versions = [] self.links = {} + # Read file with open(path, 'r') as fp: - # Read file - line = fp.readline() - while line and not line.startswith('##'): + self.lines = fp.readlines() + + section = '' + last_line = '' + in_block = False + + # loop over lines in the file + for line_no, line in enumerate(self.lines): + if match := re.fullmatch( + r'^##\s+(?P\S*)(?:\s+-\s+(?P\S+))?\s*?(?P.*?)\s*#*$', line): + # this is a version header in the form '## Name (- date) (tags*) (#*)' + section = '' + in_block = False + self._add_version_header(match['name'], match['date'], match['extra'], line_no) + + elif match := re.fullmatch(r'\[(\S*)]:\s*(\S*)\n', line): + # this is a link definition in the form '[id]: link', so add it to the link table + self.links[match[1].lower()] = match[2] + + elif len(self.versions) == 0: + # we haven't encountered any version headers yet, + # so its best to just add this line to the header string self.header += line - line = fp.readline() - version = None - section = '' - last_line = '' + elif line.isspace(): + # skip empty lines + pass + + elif match := re.fullmatch(r'###\s+(\S*?)(\s+#*)?', line): + # this is a version section header in the form '### Name' or '### Name ###' + section = match[1].title() + if section not in self.versions[-1].sections.keys(): + self.versions[-1].sections[section] = [] + in_block = False + + elif line[0] in '+-*#': + # bullet point or subheader + # subheaders are mostly preserved for round-trip accuracy, and are discouraged in favor of bullet points + # bullet points are preserved since some people like to use '+', '-' or '*' for different things + self.versions[-1].sections[section].append(line.strip()) + in_block = True + + elif in_block: + # not a bullet point, header, etc, and in a block, so this line should be appended to the last + self.versions[-1].sections[section][-1] += '\n' + line.strip() + + else: + # not a bullet point, header, etc, and not in a block, so this is the start of a new paragraph + self.versions[-1].sections[section].append(line.strip()) + in_block = True + + last_line = line + + # handle links + for version in self.versions: + if match := re.fullmatch(r'\[(.*)]', version.name): + # ref-matched link + link_id = match[1].lower() + if link_id in self.links: + version.link = self.links.pop(link_id) + version.link_id = None + version.name = match[1] + + elif version.link_id in self.links: + # id-matched link + version.link = self.links.pop(version.link_id) + + # strip whitespace from header + self.header = self.header.strip() + + def write(self, path: os.PathLike = None): + if path is None: + path = self.path + + v_links = {} + v_links.update(self.links) + + with open(path, 'w') as fp: + fp.write(self.header) + fp.write('\n\n') - while line: - if line.isspace(): - # skip empty lines - pass - elif match := re.fullmatch( - r'^##\s+(?P\S*)(?:\s+-\s+(?P\S+))?\s*?(?P.*?)\s*#*$', line): - # this is a version header in the form '## Name (- date) (tags*) (#*)' - version = VersionEntry() - section = '' + for version in self.versions: + fp.write(f'## {version}\n\n') - version.name, version.link, version.link_id = _strip_link(match['name']) + if version.link: + v_links[version.name] = version.link - if match['date']: - try: - version.date = datetime.date.fromisoformat(match['date'].strip(string.punctuation)) - except ValueError: - version.date = None + for section in version.sections: + if section: + fp.write(f'### {section}\n\n') - if match['extra']: - version.tags = [s.strip('[]') for s in re.findall(r'\[.*?]', match['extra'])] + for entry in version.sections[section]: + fp.write(entry + '\n') + if entry[0] not in '-+*': + fp.write('\n') - self.versions.append(version) + if len(version.sections[section]) > 0: + fp.write('\n') - elif match := re.fullmatch(r'###\s+(\S*?)(\s+#*)?', line): - # this is a version section header in the form '### Name' or '### Name ###' - section = match[1].title() - if section not in version.sections.keys(): - version.sections[section] = [] + for link_id, link in v_links.items(): + fp.write(f'[{link_id.lower()}]: {link}\n') - elif match := re.fullmatch(r'\[(\S*)]:\s*(\S*)\n', line): - # this is a link definition in the form '[id]: link', so add it to the link table - self.links[match[1].lower()] = match[2] + def _add_version_header(self, name, date, extra, line_no): + version = VersionEntry() - elif line[0] in bullets or last_line.isspace(): - # bullet point or new paragraph - # bullet points are preserved since some people like to use '+', '-' or '*' for different things - version.sections[section].append(line.strip()) + version.name, version.link, version.link_id = _strip_link(name) + version.line_no = line_no - else: - # not a bullet point, and no whitespace on last line, so append to the last entry - version.sections[section][-1] += '\n' + line.strip() + if date: + try: + version.date = datetime.date.fromisoformat(date.strip(string.punctuation)) + except ValueError: + version.date = None - last_line = line - line = fp.readline() + if extra: + version.tags = [s.strip('[]') for s in re.findall(r'\[.*?]', extra)] - for version in self.versions: - # handle links - if match := re.fullmatch(r'\[(.*)]', version.name): - # ref-matched link - link_id = match[1].lower() - if link_id in self.links: - version.link = self.links.pop(link_id) - version.link_id = None - version.name = match[1] - - elif version.link_id in self.links: - # id-matched link - version.link = self.links.pop(version.link_id) + self.versions.append(version) def read_version_header(line: str) -> Tuple[str, datetime.date, List[str]]: