diff --git a/exemplars/notes/book_first_yu.toml b/exemplars/notes/book_first_yu.toml new file mode 100644 index 0000000..c55f123 --- /dev/null +++ b/exemplars/notes/book_first_yu.toml @@ -0,0 +1,34 @@ +# First-occurrence full note for a single-author book. +# +# Mirrors the v1 exemplar `book_single_author_yu.toml` (same source, same +# author, same publisher) but expects CMOS 18 NOTE form rather than +# bibliography form. Cited passage is on a fictional specific page (45) +# to exercise the rule that note form takes a specific page, not a +# range. +# +# Exercises: +# - Author in NORMAL order ("Charles Yu", not "Yu, Charles") +# - Comma separation between elements (vs periods in bibliography) +# - Publisher in PARENTHESES with year +# - No place of publication (CMOS 14.30, 18th ed.) +# - Specific page number +# - Italicized book title +# - Terminal period at end of note +# +# Field naming note: this exemplar uses `expected_note` (not v1's +# `expected_bibliography`). The v2 scoring harness loader is not yet +# wired up; chunk 1 of v2 only smoke-tests these via direct tomllib +# loading. The field name change is forward-compatible. +source = "Real published book; this is a fabricated specific page citation for testing note form" +type = "note_book" +tags = ["note", "book", "single-author", "first-occurrence"] +canary = true +messy_input = "yu, charles. interior chinatown. New York: Pantheon Books, 2020. p. 45." +expected_note = "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + +[canonical] +author = "Charles Yu" +title = "Interior Chinatown" +publisher = "Pantheon Books" +year = 2020 +page = 45 diff --git a/exemplars/notes/chapter_first_doyle.toml b/exemplars/notes/chapter_first_doyle.toml new file mode 100644 index 0000000..9f5e21f --- /dev/null +++ b/exemplars/notes/chapter_first_doyle.toml @@ -0,0 +1,32 @@ +# First-occurrence full note for a chapter in an edited book. +# +# Mirrors the v1 exemplar `book_chapter_doyle.toml` (same chapter, same +# editors, same publisher, same year) but expects CMOS 18 NOTE form +# rather than bibliography form. The cited passage is on a fictional +# specific page (117) within the chapter's full range. +# +# Exercises: +# - Author in NORMAL order ("Kathleen Doyle") +# - Chapter title in straight quotes with closing comma INSIDE the +# quotes ("The Queen Mary Psalter,") +# - Lowercase "in" before the italicized book title (CMOS note form) +# - "ed." abbreviation, NOT "Edited by" +# - Editors in NORMAL order ("P. J. M. Marks and Stephen Parkin") +# - Publisher in PARENTHESES with year +# - Specific page citation +# - Terminal period +source = "Real published chapter; specific cited page is fabricated for note-form testing" +type = "note_chapter" +tags = ["note", "chapter", "edited-volume", "first-occurrence", "two-editors"] +canary = true +messy_input = "Kathleen Doyle, 'the queen mary psalter,' in The Book by Design: The Remarkable Story of the World's Greatest Invention, P. J. M. Marks and Stephen Parkin eds., Chicago: U of Chicago Press, 2023, pp. 114-120, cited p. 117." +expected_note = "Kathleen Doyle, \"The Queen Mary Psalter,\" in *The Book by Design: The Remarkable Story of the World's Greatest Invention*, ed. P. J. M. Marks and Stephen Parkin (University of Chicago Press, 2023), 117." + +[canonical] +author = "Kathleen Doyle" +chapter_title = "The Queen Mary Psalter" +book_title = "The Book by Design: The Remarkable Story of the World's Greatest Invention" +editors = "ed. P. J. M. Marks and Stephen Parkin" +publisher = "University of Chicago Press" +year = 2023 +page = 117 diff --git a/exemplars/notes/journal_first_kwon.toml b/exemplars/notes/journal_first_kwon.toml new file mode 100644 index 0000000..6c907ac --- /dev/null +++ b/exemplars/notes/journal_first_kwon.toml @@ -0,0 +1,34 @@ +# First-occurrence full note for a journal article with DOI. +# +# Mirrors the v1 exemplar `journal_with_doi_kwon.toml` (same article, +# same author, same DOI) but expects CMOS 18 NOTE form rather than +# bibliography form. The cited passage is on a fictional specific +# page within the article's full range — the bibliography form has +# `1818–59`, the note form cites a single page like `1830`. +# +# Exercises: +# - Author in NORMAL order ("Hyeyoung Kwon") +# - Article title in straight quotes with closing comma INSIDE the +# quotes ("Inclusion Work: ...,") +# - Italicized journal name +# - Volume / issue / year format same as bibliography: +# *Journal* VOL, no. ISSUE (YEAR) +# - Specific page after the (YEAR): construct +# - DOI as full https URL +# - Terminal period +source = "Real published article; specific cited page is fabricated for note-form testing" +type = "note_journal" +tags = ["note", "journal", "single-author", "first-occurrence", "doi"] +canary = true +messy_input = "Kwon, Hyeyoung. \"inclusion work: children of immigrants claiming membership in everyday life.\" American Journal of Sociology, Vol. 127, Issue 6, 2022, pp. 1818-1859. DOI: 10.1086/720277. (cited p. 1830)" +expected_note = "Hyeyoung Kwon, \"Inclusion Work: Children of Immigrants Claiming Membership in Everyday Life,\" *American Journal of Sociology* 127, no. 6 (2022): 1830, https://doi.org/10.1086/720277." + +[canonical] +author = "Hyeyoung Kwon" +article_title = "Inclusion Work: Children of Immigrants Claiming Membership in Everyday Life" +journal = "American Journal of Sociology" +volume = 127 +issue = 6 +year = 2022 +page = 1830 +doi = "https://doi.org/10.1086/720277" diff --git a/src/cmos/note_formatter.py b/src/cmos/note_formatter.py new file mode 100644 index 0000000..0e378c1 --- /dev/null +++ b/src/cmos/note_formatter.py @@ -0,0 +1,250 @@ +"""Note formatter — the v2 inner-loop artifact for CMOS 18 NB note form. + +Path B sibling of ``cmos.formatter``: a separate prompt, separate exemplar +corpus, separate inner-loop iteration target. The two artifacts share NO +mutable state and NO prompt content. v1 ``format_bibliography_entry`` +remains untouched as v2 evolves. + +Some boilerplate (OpenAI client setup, reasoning-model detection, retry +loop) is intentionally duplicated rather than imported from +``cmos.formatter``. The duplication is small (~30 lines) and the +independence is more valuable than DRY here — if the two formatters ever +need different models, retry counts, or callers, the change is local. + +Caller injection: ``format_note_entry`` accepts a ``caller`` shim so tests +and the harness can substitute a fake for the OpenAI call without touching +the network. The default caller reads ``OPENAI_API_KEY`` (via +python-dotenv) and calls the configured model with ``temperature=0`` for +non-reasoning models. + +Scope of v2 iteration 1 (this commit): FIRST-OCCURRENCE FULL NOTE form +only. Shortened subsequent-citation form is deferred — that requires +state across notes which the formatter does not currently track. The +intended pipeline is: format every note as full form, then have a +separate deterministic Python pass identify repeated sources and +generate shortened forms from the full forms. That second pass is not +yet implemented. +""" + +from __future__ import annotations + +import os +from typing import Callable + +from dotenv import load_dotenv + +from cmos.runtime_validator import validate + +# Default model. Override with OPENAI_MODEL=... in your .env. +MODEL = os.environ.get("OPENAI_MODEL", "gpt-5") + +# How many extra attempts after the first if the runtime validator rejects +# the candidate. Mirrors v1 formatter behavior — see cmos.formatter and +# the project memory on GPT-5 nondeterminism. +DEFAULT_MAX_RETRIES = 2 + +# SYSTEM_PROMPT_NOTES: the v2 note formatter's program. Iterated by the +# autoresearch loop independently of v1's SYSTEM_PROMPT. Keep it explicit, +# versioned via git history, and traceable to CMOS 18 sources. +SYSTEM_PROMPT_NOTES = """\ +You are a careful Chicago Manual of Style (CMOS), 18th edition, +notes-and-bibliography NOTE formatter. You produce CMOS 18 NOTE form +(not bibliography form). + +Your task: given a single messy citation entry in English (typically +the body of a markdown footnote definition), rewrite it in CMOS 18 +FIRST-OCCURRENCE FULL NOTE form. Output the formatted note text and +nothing else — no commentary, no leading number ("1.", "2."), no code +fences, no surrounding quotes. + +Rules you must follow (CMOS 18th edition specifically, NOTE form): + +1. AUTHOR NAMES are in NORMAL order ("First Last"), NOT inverted. + - One author: "Charles Yu" + - Two authors: "Charles Yu and Hyeyoung Kwon" + - Three authors: "Charles Yu, Hyeyoung Kwon, and Kathleen Doyle" + - Four or more authors: first author followed by ", et al." (CMOS + 14.76: notes use first author + et al. for 4+ authors, even + though bibliography lists more before truncating). + Corporate authors (e.g., "Google", "Modern Language Association") + appear as-is, not reordered. + Non-Western names already in family-first order (e.g., "Liu Xinwu", + "Murakami Haruki") stay as-is — do not Western-order them. + +2. ELEMENT SEPARATION uses COMMAS, not periods. A note is one + sentence-like construction terminated by a single period at the + very end. Periods are NOT used between author / title / journal + / publisher in note form. This is the most visible difference + from bibliography form. + +3. ITALIC AND QUOTE CONVENTIONS are the same as bibliography form: + book titles and journal titles are italicized using Markdown + asterisks (*Title*); article titles and chapter titles are + wrapped in straight double quotes. In NOTE form the closing + comma goes INSIDE the closing quote of the article/chapter + title: "Article Title," (not "Article Title",). + +4. BOOK NOTE FORM: + Author, *Book Title* (Publisher, Year), specific-page. + The publisher and year are in PARENTHESES, separated by a comma. + Place of publication is NOT included (CMOS 14.30, 18th ed.). + +5. JOURNAL ARTICLE NOTE FORM: + Author, "Article Title," *Journal Title* VOL, no. ISSUE (YEAR): + specific-page, https://doi.org/10.xxxx/yyyy. + Note that the closing comma after the article title is INSIDE + the quotes, and the colon between (YEAR) and the page is NOT + preceded by a space. + +6. CHAPTER IN EDITED BOOK NOTE FORM: + Author, "Chapter Title," in *Book Title*, ed. Editor Names + (Publisher, Year), specific-page. + Use "ed." (abbreviated), NOT "Edited by". The lowercase "in" + before the italicized book title is part of CMOS note form + and should appear as shown. + +7. TRANSLATED WORKS use "trans." (abbreviated), NOT "Translated by". + +8. SPECIFIC PAGE: notes cite the specific page (or page range) of + the passage being referenced, NOT the full page range of the + article. If the source provides only a full article range and + no specific page, use the first page of the range. Do NOT + fabricate a more specific page number than the source provides. + +9. PREFER DOIS over generic URLs. When a DOI is present, format it + as a full URL: https://doi.org/10.xxxx/yyyy (no "doi:" prefix, + no bare DOI). + +10. NO IBID. CMOS 18 deprecates "Ibid." entirely. Do not produce it + under any circumstances, even if the input contains it. If the + input is an "Ibid." reference, this iteration of the formatter + cannot resolve it (shortened-form generation is out of scope). + Return the cleanest possible full-form note from whatever + information the messy input contains. + +11. PRESERVE TERMINAL PUNCTUATION inside titles. If a title ends in + "?" or "!", keep that punctuation inside the closing quote and + add a separating comma OUTSIDE the closing quote in note form. + Example: "Are Flax Seeds All That?," *New York Times*, ... + Do NOT silently replace title-internal "?" or "!" with a comma + or period. + +12. HEADLINE-STYLE CAPITALIZATION for titles, with the same CMOS + 8.159 carve-outs as v1 bibliography form: lowercase prepositions + regardless of length (about, above, across, after, against, + among, around, as, at, before, beyond, by, for, from, in, into, + of, on, over, through, to, under, until, up, with, within, etc.) + UNLESS the preposition is the first or last word of the title + or subtitle. SUBORDINATING conjunctions (If, That, Because, + Although, Whether, Unless) are CAPITALIZED. Apply this rule + INDEPENDENTLY of the source's casing. + +13. PRESERVE DELIBERATE LOWERCASING of proper nouns (the journal + "portal: Libraries and the Academy", authors like "bell hooks", + "danah boyd", "e e cummings", "k.d. lang", brand names like + "iPhone", "eBay"). When in doubt about whether a lowercased form + is intentional, err on the side of preserving the source's form. + +14. PERIODICAL NAMES: drop a leading "The" from any periodical name + (newspaper, magazine, journal). Write "New York Times", not + "The New York Times"; "Library Quarterly", not "The Library + Quarterly". This rule applies ONLY to periodicals — do NOT drop + a leading "The" from a BOOK title or REPORT title. + +15. INCLUSIVE-NUMBER ELISION for any page range you do output (CMOS + 9.61): use the same elision rules as v1 — 1-99 keep both numbers + (3-10, 71-72); 110-199 use two-digit elision (137-49, 1818-59); + across hundreds boundary use full numbers (799-810, 1496-1504). + Always use an en-dash (–), never an ASCII hyphen (-), in number + ranges. (Most notes cite a single page, so this rule fires + rarely, but it applies when needed.) + +16. THE NOTE NUMBER PREFIX ("1. ", "2. ") is NOT part of the + formatted note text. The numbering is supplied externally by + the document or by markdown footnote rendering. DO NOT include + a leading number in your output. + +17. DO NOT invent, fetch, or guess missing metadata. If a field is + missing in the source, leave it missing. Do not fabricate + authors, publishers, years, page numbers, URLs, or DOIs. + +Output ONLY the single reformatted note. No preamble, no explanation, +no leading number, no code fences. +""" + + +Caller = Callable[[str, str], str] + + +def build_user_message(messy_entry: str) -> str: + return ( + "Reformat the following citation entry to CMOS 18 notes-and-bibliography " + "FIRST-OCCURRENCE FULL NOTE form (note form, not bibliography form). " + "Output only the reformatted note text — no leading number.\n\n" + f"{messy_entry}" + ) + + +def _is_reasoning_model(model: str) -> bool: + """Reasoning-model families (GPT-5, o-series) don't accept temperature overrides.""" + prefixes = ("gpt-5", "o1", "o3", "o4") + return any(model.startswith(p) for p in prefixes) + + +def _openai_caller(system: str, user: str) -> str: + """Default caller — hits the real OpenAI API. + + Not exercised in unit tests. Integration tests or real loop runs + use this path and require ``OPENAI_API_KEY`` in the environment or + a .env file at the project root. + """ + load_dotenv() + from openai import OpenAI # imported lazily so unit tests do not need the network + + client = OpenAI() + kwargs: dict = { + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + } + if not _is_reasoning_model(MODEL): + kwargs["temperature"] = 0 + response = client.chat.completions.create(**kwargs) + return response.choices[0].message.content or "" + + +def format_note_entry( + messy_entry: str, + caller: Caller | None = None, + max_retries: int = DEFAULT_MAX_RETRIES, +) -> str: + """Reformat a single messy citation to CMOS 18 first-occurrence note form. + + Pass a ``caller`` shim to avoid the real API (used by tests and the + harness when running with a fake formatter for loop smoke tests). + + Reasoning models (gpt-5, o-series) are nondeterministic. The candidate + is checked against ``cmos.runtime_validator`` after each call; if it + fails any structural sanity check (missing terminal period, dropped + italics, stray Ibid., unbalanced quotes/asterisks), the formatter + retries up to ``max_retries`` more times. The runtime validator is + intentionally weaker than a full CMOS rule check because at runtime + there is no Exemplar — only structural rules independent of source + type apply. The validator is reused unchanged from v1 because all + its checks (terminal period, italic balance, no Ibid, balanced + quotes) apply to note form as well. + + If every attempt fails validation, the LAST attempt is returned (we + don't raise — the caller still gets something usable, and the + failure will surface via the v2 scoring linter or human review). + """ + call = caller or _openai_caller + user_message = build_user_message(messy_entry) + last: str = "" + for _ in range(max_retries + 1): + last = call(SYSTEM_PROMPT_NOTES, user_message).strip() + if validate(last).passed: + return last + return last diff --git a/src/cmos/parser.py b/src/cmos/parser.py index e77c9b1..79571bc 100644 --- a/src/cmos/parser.py +++ b/src/cmos/parser.py @@ -5,8 +5,18 @@ section as running until the next level-2 (``##``) heading or end of file, and return each non-blank line as one entry. Bullet markers (``- ``, ``* ``) at the start of a line are stripped. -Out of scope in v1: in-prose citation rewriting, multi-line entries, nested -sections, alternative heading names (e.g., "Works Cited", "References"). +v2 scope (notes): ``find_notes`` locates pandoc-style markdown footnote +definitions ``[^marker]: text`` anywhere in the document. Single-line +definitions only — multi-line continuation (indented continuation lines) +is deferred. The reference markers in prose (the ``[^marker]`` references +themselves, without the trailing colon) are NOT collected; only the +definitions need reformatting. + +Out of scope in v1: in-prose citation rewriting, multi-line entries, +nested sections, alternative heading names (e.g., "Works Cited", +"References"). Out of scope in v2 (so far): multi-line note definitions, +shortened-form generation, reassembly of formatted notes back into the +document. """ from __future__ import annotations @@ -26,9 +36,31 @@ class ParseResult: after: str +@dataclass +class NoteDefinition: + """A single pandoc-style markdown footnote definition. + + ``marker`` is the identifier between ``[^`` and ``]:`` (e.g., ``"1"`` + or ``"smith2020"``). ``text`` is the note body with surrounding + whitespace stripped. ``line_number`` is the 0-indexed line in the + source document where the definition appeared — currently unused but + captured for future reassembly support. + """ + + marker: str + text: str + line_number: int + + +@dataclass +class NotesParseResult: + definitions: list[NoteDefinition] + + _HEADING_RE = re.compile(r"^##\s+bibliography\s*$", re.IGNORECASE) _LEVEL_TWO_RE = re.compile(r"^##\s+\S") _BULLET_RE = re.compile(r"^[-*]\s+") +_NOTE_DEF_RE = re.compile(r"^\[\^([^\]]+)\]:\s*(.*?)\s*$") def split_bibliography(text: str) -> ParseResult: @@ -64,3 +96,30 @@ def split_bibliography(text: str) -> ParseResult: entries.append(stripped) return ParseResult(before=before, entries=entries, after=after) + + +def find_notes(text: str) -> NotesParseResult: + """Find pandoc-style markdown footnote definitions in ``text``. + + Targets the most common form: ``[^marker]: note text`` on a single + line. The marker can be numeric (``1``) or named (``smith2020``). + Multi-line definitions (where the note body continues on indented + subsequent lines) are out of scope for this iteration; only the + first line is captured. + + Returns a ``NotesParseResult`` with definitions in the order they + appear in the document. If the document contains no footnote + definitions, returns an empty list rather than raising — a draft + with no notes is a valid (if uninteresting) input. + """ + definitions: list[NoteDefinition] = [] + for line_number, line in enumerate(text.splitlines()): + match = _NOTE_DEF_RE.match(line) + if match is None: + continue + marker = match.group(1) + body = match.group(2) + definitions.append( + NoteDefinition(marker=marker, text=body, line_number=line_number) + ) + return NotesParseResult(definitions=definitions) diff --git a/tests/test_note_formatter.py b/tests/test_note_formatter.py new file mode 100644 index 0000000..a985e86 --- /dev/null +++ b/tests/test_note_formatter.py @@ -0,0 +1,117 @@ +"""Tests for src/cmos/note_formatter.py — the v2 inner-loop iterable artifact. + +Mirrors tests/test_formatter.py in shape and discipline. The note formatter +is iterated independently of the bibliography formatter (Path B); these +tests pin the stable seams (caller injection, retry loop, prompt smoke +checks) and leave LLM output shape testing to the v2 exemplar canary. + +No real API calls in this file. Tests use injected fake callers. +""" + +from cmos.note_formatter import ( + MODEL, + SYSTEM_PROMPT_NOTES, + build_user_message, + format_note_entry, +) + + +def test_default_model_is_gpt5(): + # Same default as v1. Override with OPENAI_MODEL=... in .env. + assert MODEL == "gpt-5" + + +def test_system_prompt_mentions_cmos_18_note_form(): + assert "CMOS" in SYSTEM_PROMPT_NOTES + assert "18" in SYSTEM_PROMPT_NOTES + assert "NOTE" in SYSTEM_PROMPT_NOTES + + +def test_system_prompt_mentions_no_inversion(): + # The single most distinctive difference from bibliography form: notes + # use normal-order author names ("First Last"), not inverted. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "normal" in lower or "not inverted" in lower + + +def test_system_prompt_mentions_comma_separation(): + # The second most distinctive difference: commas between elements, + # not periods. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "comma" in lower + + +def test_system_prompt_excludes_leading_number(): + # The note number prefix ("1. ", "2. ") is supplied externally; the + # formatter must not include it. The prompt must say so. + lower = SYSTEM_PROMPT_NOTES.lower() + assert "leading number" in lower or "do not include" in lower + + +def test_system_prompt_mentions_no_ibid(): + # CMOS 18 deprecates Ibid., same as v1. + assert "Ibid" in SYSTEM_PROMPT_NOTES or "ibid" in SYSTEM_PROMPT_NOTES.lower() + + +def test_build_user_message_contains_the_messy_input(): + msg = build_user_message("yu, charles. interior chinatown. 2020, 45") + assert "yu, charles. interior chinatown. 2020, 45" in msg + + +def test_note_formatter_uses_injected_caller(): + """Same caller-injection seam as v1. Tests can substitute a fake without + touching the network.""" + recorded: dict = {} + + def fake_caller(system: str, user: str) -> str: + recorded["system"] = system + recorded["user"] = user + return "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + output = format_note_entry( + "yu, charles. interior chinatown. New York: Pantheon Books, 2020, p 45.", + caller=fake_caller, + ) + assert output == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + assert recorded["system"] == SYSTEM_PROMPT_NOTES + assert "yu, charles" in recorded["user"] + + +def test_note_formatter_strips_whitespace_from_caller_output(): + def fake_caller(system: str, user: str) -> str: + return " Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45. \n" + + output = format_note_entry("anything", caller=fake_caller) + assert output == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + +def test_note_formatter_retries_on_validator_failure(): + """If the runtime validator rejects the first attempt (e.g., italics + dropped), the formatter retries. Same retry semantics as v1.""" + attempts = [] + + def flaky_caller(system: str, user: str) -> str: + attempts.append(len(attempts) + 1) + if len(attempts) == 1: + # First attempt: italics dropped — validator should fail. + return "Charles Yu, Interior Chinatown (Pantheon Books, 2020), 45." + return "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + output = format_note_entry("messy yu", caller=flaky_caller) + assert "*Interior Chinatown*" in output + assert len(attempts) == 2 + + +def test_note_formatter_returns_last_attempt_after_max_retries(): + """If every attempt fails validation, return the last attempt and don't + loop forever. Same as v1 — the failure surfaces via scoring or human + review, not via a runtime exception.""" + attempts = [] + + def always_bad(system: str, user: str) -> str: + attempts.append(1) + return "Charles Yu, Interior Chinatown (Pantheon Books, 2020), 45." + + output = format_note_entry("messy", caller=always_bad, max_retries=2) + assert len(attempts) == 3 # 1 initial + 2 retries + assert "*" not in output diff --git a/tests/test_parser.py b/tests/test_parser.py index 508464d..c6e1d5f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -4,11 +4,15 @@ v1 scope: locate a ``## Bibliography`` heading (case-insensitive) and return the prose before it, the list of entries inside it, and the prose after it. The section ends at the next ``##`` (level-2) heading or end of file. Blank lines and bullet markers (``- `` or ``* ``) are stripped from entries. + +v2 (notes) parser scope: find pandoc-style markdown footnote definitions +``[^marker]: text`` anywhere in the document. Single-line definitions only; +multi-line continuation is deferred to a later chunk. """ import pytest -from cmos.parser import NoBibliographyError, split_bibliography +from cmos.parser import NoBibliographyError, find_notes, split_bibliography def test_three_entries_under_bibliography_heading(): @@ -74,3 +78,84 @@ Only entry. def test_no_bibliography_raises(): with pytest.raises(NoBibliographyError): split_bibliography("## Introduction\n\nJust prose.\n") + + +# --- find_notes (v2 parser, pandoc-style markdown footnote definitions) ---- + + +def test_find_notes_single_definition(): + draft = """\ +Some prose with a citation.[^1] + +[^1]: Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45. +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "1" + assert result.definitions[0].text == "Charles Yu, *Interior Chinatown* (Pantheon Books, 2020), 45." + + +def test_find_notes_multiple_definitions_in_order(): + draft = """\ +Prose.[^1] More prose.[^2] And again.[^3] + +[^1]: First note. +[^2]: Second note. +[^3]: Third note. +""" + result = find_notes(draft) + assert len(result.definitions) == 3 + assert [d.marker for d in result.definitions] == ["1", "2", "3"] + assert [d.text for d in result.definitions] == [ + "First note.", + "Second note.", + "Third note.", + ] + + +def test_find_notes_named_marker(): + draft = """\ +Citation.[^smith2020] + +[^smith2020]: Smith, J. (2020). Some work. +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].marker == "smith2020" + assert result.definitions[0].text == "Smith, J. (2020). Some work." + + +def test_find_notes_returns_empty_when_no_definitions(): + draft = "Just prose with no footnote definitions.\n" + result = find_notes(draft) + assert result.definitions == [] + + +def test_find_notes_records_line_number(): + draft = """\ +Line zero. +Line one. + +[^1]: Note on line three. +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].line_number == 3 + + +def test_find_notes_strips_trailing_whitespace_from_text(): + draft = "[^1]: Note text with trailing spaces. \n" + result = find_notes(draft) + assert result.definitions[0].text == "Note text with trailing spaces." + + +def test_find_notes_definition_can_appear_before_reference(): + # Pandoc allows definitions anywhere in the doc, not just at the bottom. + draft = """\ +[^1]: A definition before the reference. + +Some prose that cites it.[^1] +""" + result = find_notes(draft) + assert len(result.definitions) == 1 + assert result.definitions[0].text == "A definition before the reference."