diff --git a/cvelib/wizard.py b/cvelib/wizard.py index 80e65bd..5d6cfd9 100644 --- a/cvelib/wizard.py +++ b/cvelib/wizard.py @@ -112,6 +112,20 @@ def _parsePriorityInput(input_str: str) -> str: return "" +def _asListItem(line: str) -> Optional[Tuple[str, str]]: + """If 'line' is a list item, return (marker, content), otherwise None. + + A list item is a line that, after optional leading whitespace, begins with + a '*' or '-' marker followed by whitespace. The marker is returned exactly + as typed and 'content' is the remaining text with surrounding whitespace + stripped. + """ + m: Optional[re.Match[str]] = re.match(r"^\s*([*-])\s+(.*)$", line) + if m is None: + return None + return m.group(1), m.group(2).strip() + + def _formatAsNoteText(text: str, attribution: str = "PERSON") -> str: """Format CVE Notes text with proper line wrapping and attribution. @@ -129,6 +143,15 @@ def _formatAsNoteText(text: str, attribution: str = "PERSON") -> str: 2nd paragraph . 3rd paragraph + + Lines that begin with a '*' or '-' list marker are preserved as individual + list items rather than being folded into the surrounding text. Eg, + 'These were fixed by:\n* url 1\n* url 2' becomes: + + Notes: + PERSON> These were fixed by: + * url 1 + * url 2 """ if not text.strip(): return "" @@ -140,15 +163,31 @@ def _formatAsNoteText(text: str, attribution: str = "PERSON") -> str: # Split on double newlines to get paragraphs raw_paragraphs: List[str] = normalized_text.split("\n\n") - paragraph: str - paragraphs: List[str] = [] - for paragraph in raw_paragraphs: - # Replace single newlines with spaces within each paragraph - cleaned_para: str = paragraph.replace("\n", " ") - # Normalize whitespace - cleaned_para = " ".join(cleaned_para.split()) - if cleaned_para: # Skip empty paragraphs - paragraphs.append(cleaned_para) + # Parse each paragraph into an ordered list of segments. A segment is + # either ("text", ) for a run of consecutive non-list lines or + # ("list", , ) for a single list item line. Paragraphs + # that yield no segments are dropped. + paragraphs: List[List[Tuple[str, ...]]] = [] + for raw_paragraph in raw_paragraphs: + segments: List[Tuple[str, ...]] = [] + text_buffer: List[str] = [] + for line in raw_paragraph.split("\n"): + item: Optional[Tuple[str, str]] = _asListItem(line) + if item is not None: + joined: str = " ".join(" ".join(text_buffer).split()) + if joined: + segments.append(("text", joined)) + text_buffer = [] + segments.append(("list", item[0], item[1])) + else: + text_buffer.append(line) + # Flush any trailing text run + joined = " ".join(" ".join(text_buffer).split()) + if joined: + segments.append(("text", joined)) + + if segments: + paragraphs.append(segments) # Wrap the text to 'cve_file_line_width' characters, accounting for # attribution prefix @@ -163,39 +202,59 @@ def _formatAsNoteText(text: str, attribution: str = "PERSON") -> str: continuation_width: int = cve_file_line_width - len(continuation_prefix) result_lines: List[str] = [] + emitted_attribution: bool = False para_idx: int - for para_idx, paragraph in enumerate(paragraphs): - # Wrap the current paragraph - wrapped_lines: List[str] = textwrap.wrap( - paragraph, - width=first_line_width if para_idx == 0 else continuation_width, - break_on_hyphens=False, - ) - - if not wrapped_lines: - continue - - if para_idx == 0: - # First paragraph: start with attribution - result_lines.append(f"{first_line_prefix}{wrapped_lines[0]}") - # Continuation lines for first paragraph - for line in wrapped_lines[1:]: - continuation_wrapped = textwrap.wrap( - line, width=continuation_width, break_on_hyphens=False - ) - for cont_line in continuation_wrapped: - result_lines.append(f"{continuation_prefix}{cont_line}") - else: - # Add paragraph separator before subsequent paragraphs + for para_idx, segments in enumerate(paragraphs): + # Add paragraph separator before subsequent paragraphs + if para_idx > 0: result_lines.append(paragraph_separator) - # Add all lines of the paragraph with continuation prefix - for line in wrapped_lines: - continuation_wrapped = textwrap.wrap( - line, width=continuation_width, break_on_hyphens=False + + for segment in segments: + if segment[0] == "text": + content: str = segment[1] + if not emitted_attribution: + # First emitted line of the note carries the attribution + wrapped: List[str] = textwrap.wrap( + content, width=first_line_width, break_on_hyphens=False + ) + if not wrapped: + continue + result_lines.append(f"{first_line_prefix}{wrapped[0]}") + emitted_attribution = True + for line in wrapped[1:]: + result_lines.append(f"{continuation_prefix}{line}") + else: + wrapped = textwrap.wrap( + content, width=continuation_width, break_on_hyphens=False + ) + for line in wrapped: + result_lines.append(f"{continuation_prefix}{line}") + else: + # List item: render as " " with a hanging + # indent so wrapped continuations align under the content + marker: str = segment[1] + content = segment[2] + if not emitted_attribution: + # Note begins with a list item; emit a bare attribution + # prefix on its own line and start the list below it + result_lines.append(first_line_prefix.rstrip()) + emitted_attribution = True + + bullet_prefix: str = f"{continuation_prefix}{marker} " + hanging_indent: str = " " * len(bullet_prefix) + wrapped = textwrap.wrap( + content, + width=cve_file_line_width - len(bullet_prefix), + break_on_hyphens=False, ) - for cont_line in continuation_wrapped: - result_lines.append(f"{continuation_prefix}{cont_line}") + if not wrapped: + # Marker followed by whitespace but no content, eg "* " + result_lines.append(bullet_prefix.rstrip()) + continue + result_lines.append(f"{bullet_prefix}{wrapped[0]}") + for line in wrapped[1:]: + result_lines.append(f"{hanging_indent}{line}") return "\n".join(result_lines) diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 1fd461d..4536194 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -522,6 +522,86 @@ def test__formatAsNoteText_various_empty(self): result = cvelib.wizard._formatAsNoteText(text, "author") self.assertEqual(result, "") + def test__asListItem(self): + """Test _asListItem list item detection""" + # '*' and '-' markers, with varying leading/trailing whitespace + self.assertEqual(cvelib.wizard._asListItem("* url 1"), ("*", "url 1")) + self.assertEqual(cvelib.wizard._asListItem("- url 1"), ("-", "url 1")) + self.assertEqual(cvelib.wizard._asListItem(" * url 1"), ("*", "url 1")) + self.assertEqual(cvelib.wizard._asListItem("\t- url 1 "), ("-", "url 1")) + # marker requires trailing whitespace before content + self.assertEqual(cvelib.wizard._asListItem("*url 1"), None) + self.assertEqual(cvelib.wizard._asListItem("-url 1"), None) + # a bare marker with no trailing whitespace is not a list item + self.assertEqual(cvelib.wizard._asListItem("*"), None) + self.assertEqual(cvelib.wizard._asListItem("-"), None) + # not a list item + self.assertEqual(cvelib.wizard._asListItem("plain text"), None) + self.assertEqual(cvelib.wizard._asListItem("a * b"), None) + # marker with no content + self.assertEqual(cvelib.wizard._asListItem("* "), ("*", "")) + + def test__formatAsNoteText_list_items(self): + """Test _formatAsNoteText preserves '*' and '-' list items""" + # The reported case: intro text followed by a '*' list + text = "These were fixed by:\n* url 1\n* url 2" + formatted = cvelib.wizard._formatAsNoteText(text, "jdstrand") + self.assertEqual( + formatted, + " jdstrand> These were fixed by:\n * url 1\n * url 2", + ) + + # Same with '-' markers; markers are preserved exactly as typed + text = "These were fixed by:\n- url 1\n- url 2" + formatted = cvelib.wizard._formatAsNoteText(text, "jdstrand") + self.assertEqual( + formatted, + " jdstrand> These were fixed by:\n - url 1\n - url 2", + ) + + # Mixed markers are each preserved + text = "fixes:\n* a\n- b" + formatted = cvelib.wizard._formatAsNoteText(text, "jdstrand") + self.assertEqual(formatted, " jdstrand> fixes:\n * a\n - b") + + def test__formatAsNoteText_list_starts_note(self): + """Test _formatAsNoteText when a note begins with a list item""" + # A dangling attribution prefix on its own line is expected + text = "* url 1\n* url 2" + formatted = cvelib.wizard._formatAsNoteText(text, "jdstrand") + self.assertEqual(formatted, " jdstrand>\n * url 1\n * url 2") + + def test__formatAsNoteText_list_in_paragraph(self): + """Test _formatAsNoteText with a list between text paragraphs""" + text = "Intro line\n* item one\n* item two\n\nTrailing paragraph here." + formatted = cvelib.wizard._formatAsNoteText(text, "jdstrand") + self.assertEqual( + formatted, + " jdstrand> Intro line\n" + " * item one\n" + " * item two\n" + " .\n" + " Trailing paragraph here.", + ) + + def test__formatAsNoteText_list_item_wraps(self): + """Test _formatAsNoteText wraps long list items with a hanging indent""" + long_item = "a very long url that should wrap " * 4 + text = "* " + long_item + formatted = cvelib.wizard._formatAsNoteText(text, "jdstrand") + lines = formatted.split("\n") + + # Dangling attribution, then the bullet line, then hanging continuation + self.assertEqual(lines[0], " jdstrand>") + self.assertTrue(lines[1].startswith(" * ")) + # Continuation lines align under the content (after " * ") + for line in lines[2:]: + self.assertTrue(line.startswith(" ")) + self.assertFalse(line.startswith(" *")) + # All lines fit within the configured width + for line in lines: + self.assertLessEqual(len(line), 80) + def test__formatAsNoteText_empty_wrap(self): """Test _formatAsNoteText when textwrap returns empty list""" # Create a string that would cause textwrap to return empty list