diff --git a/mdformat_footnote/__init__.py b/mdformat_footnote/__init__.py index d3a2974..0352e0c 100644 --- a/mdformat_footnote/__init__.py +++ b/mdformat_footnote/__init__.py @@ -1,5 +1,6 @@ """An mdformat plugin for parsing/validating footnotes""" __version__ = "0.1.2" +__plugin_name__ = "footnote" -from .plugin import RENDERERS, update_mdit # noqa: F401 +from .plugin import RENDERERS, add_cli_argument_group, update_mdit # noqa: F401 diff --git a/mdformat_footnote/_helpers.py b/mdformat_footnote/_helpers.py new file mode 100644 index 0000000..a3d3652 --- /dev/null +++ b/mdformat_footnote/_helpers.py @@ -0,0 +1,17 @@ +"""Helper functions for plugin configuration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from . import __plugin_name__ + +ContextOptions = Mapping[str, Any] + + +def get_conf(options: ContextOptions, key: str) -> bool | str | int | None: + """Read setting from mdformat configuration Context.""" + if (api := options["mdformat"].get(key)) is not None: + return api + return options["mdformat"].get("plugin", {}).get(__plugin_name__, {}).get(key) diff --git a/mdformat_footnote/_reorder.py b/mdformat_footnote/_reorder.py new file mode 100644 index 0000000..71c1bbf --- /dev/null +++ b/mdformat_footnote/_reorder.py @@ -0,0 +1,285 @@ +"""Footnote ID and subId normalization logic.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import re + +from markdown_it.rules_core import StateCore + +_FOOTNOTE_REF_PATTERN = re.compile(r"\[\^([^\]]+)\]") + + +@dataclass +class _FootnoteCategories: + """Categorized footnotes for reordering.""" + + body_referenced: list[tuple[int, str, str]] + nested_only: set[str] + fence_only: list[str] + true_orphans: list[str] + + @property + def body_labels(self) -> set[str]: + return {label for _, _, label in self.body_referenced} + + +@dataclass +class _ReorderState: + """Mutable state for footnote reordering.""" + + old_list: dict + refs: dict + new_list: dict = field(default_factory=dict) + old_to_new_id: dict[int, int] = field(default_factory=dict) + processed: set[str] = field(default_factory=set) + new_id: int = 0 + + def _find_def_by_label(self, label: str) -> dict: + for fn_data in self.old_list.values(): + if fn_data.get("label") == label: + return fn_data.copy() + return {"label": label, "count": 0} + + def _find_old_id_by_label(self, label: str) -> int | None: + for old_id, fn_data in self.old_list.items(): + if fn_data.get("label") == label: + return old_id + return None + + def add_footnote( + self, label: str, label_key: str, old_id: int | None = None + ) -> None: + """Add a footnote to the new list and update mappings.""" + if label in self.processed: + return + + self.new_list[self.new_id] = self._find_def_by_label(label) + + effective_old_id = old_id or self._find_old_id_by_label(label) + if effective_old_id is not None: + self.old_to_new_id[effective_old_id] = self.new_id + + self.refs[label_key] = self.new_id + self.processed.add(label) + self.new_id += 1 + + +def _collect_refs_in_fences(tokens: list) -> list[str]: + """Collect footnote labels referenced in fence tokens, preserving order.""" + refs: list[str] = [] + seen: set[str] = set() + for token in tokens: + if token.type != "fence" or not token.content: + continue + for match in _FOOTNOTE_REF_PATTERN.finditer(token.content): + label = match.group(1) + if label not in seen: + refs.append(label) + seen.add(label) + return refs + + +def _build_dependency_graph(tokens: list) -> dict[str, set[str]]: + """Build a graph of which footnotes reference which others.""" + graph: dict[str, set[str]] = {} + current_def_label: str | None = None + + for token in tokens: + match token.type: + case "footnote_reference_open": + current_def_label = token.meta.get("label") + if current_def_label: + graph.setdefault(current_def_label, set()) + case "footnote_reference_close": + current_def_label = None + case _ if current_def_label is not None: + _collect_nested_refs(token, graph[current_def_label]) + + return graph + + +def _collect_nested_refs(token, ref_set: set[str]) -> None: + """Collect footnote labels referenced from a token and its children.""" + if token.type == "footnote_ref" and token.meta: + ref_set.add(token.meta["label"]) + for child in token.children or []: + _collect_nested_refs(child, ref_set) + + +def _categorize_footnotes( + refs: dict, + footnote_deps: dict[str, set[str]], + refs_in_fences: list[str], +) -> _FootnoteCategories: + """Categorize footnotes.""" + referenced_by_footnotes: set[str] = set() + for refs_set in footnote_deps.values(): + referenced_by_footnotes.update(refs_set) + + refs_in_fences_set = set(refs_in_fences) + + body_referenced: list[tuple[int, str, str]] = [] + nested_only: set[str] = set() + fence_only_set: set[str] = set() + true_orphans: list[str] = [] + + for label_key, old_id in refs.items(): + label = label_key[1:] + match ( + old_id >= 0, + label in referenced_by_footnotes, + label in refs_in_fences_set, + ): + case (True, _, _): + body_referenced.append((old_id, label_key, label)) + case (False, True, _): + nested_only.add(label) + case (False, False, True): + fence_only_set.add(label) + case _: + true_orphans.append(label_key) + + body_referenced.sort(key=lambda x: x[0]) + fence_only = [label for label in refs_in_fences if label in fence_only_set] + + return _FootnoteCategories(body_referenced, nested_only, fence_only, true_orphans) + + +def _process_nested_for_parent( + parent_label: str, + footnote_deps: dict[str, set[str]], + state: _ReorderState, + skip_labels: set[str], +) -> None: + """Process nested footnotes referenced by a parent footnote.""" + for nested_label in footnote_deps.get(parent_label, []): + if nested_label not in skip_labels: + state.add_footnote(nested_label, f":{nested_label}") + + +def _build_reordered_list( + categories: _FootnoteCategories, + footnote_deps: dict[str, set[str]], + old_list: dict, + refs: dict, + keep_orphans: bool, +) -> _ReorderState: + """Build the reordered footnote list from categorized footnotes.""" + state = _ReorderState(old_list=old_list, refs=refs) + skip_labels = categories.body_labels | set(categories.true_orphans) + + for old_id, label_key, label in categories.body_referenced: + state.add_footnote(label, label_key, old_id) + _process_nested_for_parent(label, footnote_deps, state, skip_labels) + + for nested_label in categories.nested_only: + state.add_footnote(nested_label, f":{nested_label}") + + for fence_label in categories.fence_only: + state.add_footnote(fence_label, f":{fence_label}") + + if keep_orphans: + for orphan_key in categories.true_orphans: + state.add_footnote(orphan_key[1:], orphan_key) + + return state + + +def _update_token_ids(tokens: list, old_to_new_id: dict[int, int]) -> None: + """Recursively update footnote IDs in tokens.""" + for token in tokens: + if token.type in ("footnote_ref", "footnote_anchor"): + if token.meta and (old_id := token.meta.get("id")) in old_to_new_id: + token.meta["id"] = old_to_new_id[old_id] + for child in token.children or []: + _update_token_ids([child], old_to_new_id) + + +def _partition_refs_by_context(tokens: list) -> tuple[list, dict[str, list]]: + """Partition footnote refs into body refs and definition refs.""" + body_refs: list = [] + def_refs: dict[str, list] = {} + current_def_label: str | None = None + + for token in tokens: + match token.type: + case "footnote_reference_open": + current_def_label = token.meta.get("label") + if current_def_label: + def_refs.setdefault(current_def_label, []) + case "footnote_reference_close": + current_def_label = None + case _ if current_def_label is None: + _collect_refs(token, body_refs) + case _: + _collect_refs(token, def_refs.setdefault(current_def_label, [])) + + return body_refs, def_refs + + +def _assign_subids_to_refs(ref_tokens: list, counters: dict[int, int]) -> None: + """Assign sequential subIds to a list of ref tokens.""" + for ref_token in ref_tokens: + fn_id = ref_token.meta["id"] + ref_token.meta["subId"] = counters.get(fn_id, 0) + counters[fn_id] = counters.get(fn_id, 0) + 1 + + +def _reassign_subids(tokens: list, refs: dict, footnote_list: dict) -> None: + """Reassign subIds based on output order: body refs first, then definition refs.""" + body_refs, def_refs = _partition_refs_by_context(tokens) + subid_counters: dict[int, int] = {} + + _assign_subids_to_refs(body_refs, subid_counters) + + for label_key in refs: + label = label_key[1:] + if label in def_refs: + _assign_subids_to_refs(def_refs[label], subid_counters) + + for fn_id, count in subid_counters.items(): + if fn_id in footnote_list: + footnote_list[fn_id]["count"] = count + + +def _collect_refs(token, ref_list: list) -> None: + """Collect footnote_ref tokens from a token and its children.""" + if token.type == "footnote_ref" and token.meta: + ref_list.append(token) + for child in token.children or []: + _collect_refs(child, ref_list) + + +def _get_footnote_data(state: StateCore) -> tuple[dict, dict] | None: + """Extract footnote refs and list from state, or None if missing.""" + footnote_data = state.env.get("footnotes", {}) + refs = footnote_data.get("refs", {}) + if not refs: + return None + return refs, footnote_data.get("list", {}) + + +def reorder_footnotes_by_definition( + state: StateCore, keep_orphans: bool = False +) -> None: + """Reorder footnotes by reference order, fix IDs, and handle orphans.""" + if (data := _get_footnote_data(state)) is None: + return + + refs, old_list = data + footnote_deps = _build_dependency_graph(state.tokens) + refs_in_fences = _collect_refs_in_fences(state.tokens) + categories = _categorize_footnotes(refs, footnote_deps, refs_in_fences) + + if not keep_orphans: + for orphan_key in categories.true_orphans: + del refs[orphan_key] + + reorder_state = _build_reordered_list( + categories, footnote_deps, old_list, refs, keep_orphans + ) + + state.env["footnotes"]["list"] = reorder_state.new_list + _update_token_ids(state.tokens, reorder_state.old_to_new_id) + _reassign_subids(state.tokens, refs, reorder_state.new_list) diff --git a/mdformat_footnote/plugin.py b/mdformat_footnote/plugin.py index 0ea5d07..523411b 100644 --- a/mdformat_footnote/plugin.py +++ b/mdformat_footnote/plugin.py @@ -1,6 +1,8 @@ from __future__ import annotations +import argparse from collections.abc import Mapping +from functools import partial import textwrap from markdown_it import MarkdownIt @@ -8,6 +10,31 @@ from mdformat.renderer.typing import Render from mdit_py_plugins.footnote import footnote_plugin +from ._helpers import ContextOptions, get_conf +from ._reorder import reorder_footnotes_by_definition + + +def _keep_orphans(options: ContextOptions) -> bool: + """Check if orphan footnotes should be preserved.""" + return bool(get_conf(options, "keep_orphans")) or False + + +def add_cli_argument_group(group: argparse._ArgumentGroup) -> None: + """Add options to the mdformat CLI. + + Stored in `mdit.options["mdformat"]["plugin"]["footnote"]` + """ + group.add_argument( + "--keep-footnote-orphans", + action="store_const", + const=True, + dest="keep_orphans", + help=( + "Keep footnote definitions that are never referenced " + "(default: remove them)" + ), + ) + def update_mdit(mdit: MarkdownIt) -> None: """Update the parser, adding the footnote plugin.""" @@ -15,6 +42,11 @@ def update_mdit(mdit: MarkdownIt) -> None: # Disable inline footnotes for now, since we don't have rendering # support for them yet. mdit.disable("footnote_inline") + # Reorder footnotes by reference order, fix IDs, and handle orphans. + # Must run before footnote_tail. + keep_orphans = _keep_orphans(mdit.options) + reorder_fn = partial(reorder_footnotes_by_definition, keep_orphans=keep_orphans) + mdit.core.ruler.before("footnote_tail", "reorder_footnotes", reorder_fn) def _footnote_ref_renderer(node: RenderTreeNode, context: RenderContext) -> str: diff --git a/tests/fixture_helpers.py b/tests/fixture_helpers.py new file mode 100644 index 0000000..fede2f8 --- /dev/null +++ b/tests/fixture_helpers.py @@ -0,0 +1,23 @@ +"""Helper utilities for loading test fixtures.""" + +from pathlib import Path + +from markdown_it.utils import read_fixture_file + + +def load_fixtures(filename: str) -> list[tuple[int, str, str, str]]: + """Load fixtures from a file in tests/fixtures/ directory.""" + fixture_path = Path(__file__).parent / "fixtures" / filename + return read_fixture_file(fixture_path) + + +def get_fixture(filename: str, title: str) -> tuple[str, str]: + """Get a specific fixture by title from a file.""" + fixtures = load_fixtures(filename) + for _, fixture_title, input_text, expected_output in fixtures: + if fixture_title == title: + return input_text, expected_output + available = [f[1] for f in fixtures] + raise ValueError( + f"Fixture '{title}' not found in {filename}. Available: {available}" + ) diff --git a/tests/fixtures.md b/tests/fixtures.md deleted file mode 100644 index 05275ae..0000000 --- a/tests/fixtures.md +++ /dev/null @@ -1,118 +0,0 @@ -a test -. -This is the input Markdown test, -then below add the expected output. -. -This is the input Markdown test, -then below add the expected output. -. - - -another test -. -Some *markdown* - -* a -* b -- c -. -Some *markdown* - -- a -- b - -* c -. - - -Test Footnotes -. -# Now some markdown -Here is a footnote reference,[^1] and another.[^longnote] -[^1]: Here is the footnote. -[^longnote]: Here's one with multiple blocks. - - Subsequent paragraphs are indented to show that they -belong to the previous footnote. - - Third paragraph here. -. -# Now some markdown - -Here is a footnote reference,[^1] and another.[^longnote] - -[^1]: Here is the footnote. - -[^longnote]: Here's one with multiple blocks. - - Subsequent paragraphs are indented to show that they - belong to the previous footnote. - - Third paragraph here. -. - - -Empty footnote -. -Here is a footnote reference [^emptynote] - -[^emptynote]: -. -Here is a footnote reference [^emptynote] - -[^emptynote]: -. - - -Move footnote definitions to the end (but before link ref defs) -. -[link]: https://www.python.org -[^1]: Here is the footnote. - -# Now we reference them -Here is a footnote reference[^1] -Here is a [link] - -. -# Now we reference them - -Here is a footnote reference[^1] -Here is a [link] - -[^1]: Here is the footnote. - -[link]: https://www.python.org -. - -footnote-indentation -. -[^a] - -[^a]: first paragraph with -unindented next line. - - paragraph with - indented next line - - paragraph with -unindented next line - - ``` - content - ``` -. -[^a] - -[^a]: first paragraph with - unindented next line. - - paragraph with - indented next line - - paragraph with - unindented next line - - ``` - content - ``` -. diff --git a/tests/fixtures/cli_integration.md b/tests/fixtures/cli_integration.md new file mode 100644 index 0000000..223838a --- /dev/null +++ b/tests/fixtures/cli_integration.md @@ -0,0 +1,14 @@ +CLI keep orphans flag test +. +Referenced [^used] + +[^orphan]: This is never referenced + +[^used]: This is used +. +Referenced [^used] + +[^used]: This is used + +[^orphan]: This is never referenced +. diff --git a/tests/fixtures/cli_options.md b/tests/fixtures/cli_options.md new file mode 100644 index 0000000..6afdf31 --- /dev/null +++ b/tests/fixtures/cli_options.md @@ -0,0 +1,45 @@ +Default removes orphans +. +Referenced [^used] + +[^orphan]: This is never referenced + +[^used]: This is used + +[^another-orphan]: Also unused +. +Referenced [^used] + +[^used]: This is used +. + + +Keep orphans flag preserves orphans +. +Referenced [^used] + +[^orphan]: This is never referenced + +[^used]: This is used +. +Referenced [^used] + +[^used]: This is used + +[^orphan]: This is never referenced +. + + +Nested footnotes not treated as orphans +. +Body [^a] + +[^a]: First [^b] +[^b]: Second +. +Body [^a] + +[^a]: First [^b] + +[^b]: Second +. diff --git a/tests/fixtures/footnote.md b/tests/fixtures/footnote.md new file mode 100644 index 0000000..49fc307 --- /dev/null +++ b/tests/fixtures/footnote.md @@ -0,0 +1,477 @@ +a test +. +This is the input Markdown test, +then below add the expected output. +. +This is the input Markdown test, +then below add the expected output. +. + + +another test +. +Some *markdown* + +* a +* b +- c +. +Some *markdown* + +- a +- b + +* c +. + + +Test Footnotes +. +# Now some markdown +Here is a footnote reference,[^1] and another.[^longnote] +[^1]: Here is the footnote. +[^longnote]: Here's one with multiple blocks. + + Subsequent paragraphs are indented to show that they +belong to the previous footnote. + + Third paragraph here. +. +# Now some markdown + +Here is a footnote reference,[^1] and another.[^longnote] + +[^1]: Here is the footnote. + +[^longnote]: Here's one with multiple blocks. + + Subsequent paragraphs are indented to show that they + belong to the previous footnote. + + Third paragraph here. +. + + +Empty footnote +. +Here is a footnote reference [^emptynote] + +[^emptynote]: +. +Here is a footnote reference [^emptynote] + +[^emptynote]: +. + + +Move footnote definitions to the end (but before link ref defs) +. +[link]: https://www.python.org +[^1]: Here is the footnote. + +# Now we reference them +Here is a footnote reference[^1] +Here is a [link] + +. +# Now we reference them + +Here is a footnote reference[^1] +Here is a [link] + +[^1]: Here is the footnote. + +[link]: https://www.python.org +. + +footnote-indentation +. +[^a] + +[^a]: first paragraph with +unindented next line. + + paragraph with + indented next line + + paragraph with +unindented next line + + ``` + content + ``` +. +[^a] + +[^a]: first paragraph with + unindented next line. + + paragraph with + indented next line + + paragraph with + unindented next line + + ``` + content + ``` +. + + +footnote-ref-inside-footnote (issue #7) +. +[^a]: lorem +[^c]: ipsum [^a] +. +[^a]: lorem +. + + +nested-footnote-refs (issue #8) +. +[^a]: Lorem. [^b] + +[^b]: Ipsum. + +A [^b] +. +A [^b] + +[^b]: Ipsum. +. + + +Footnote in table nested in admonition (issue #22) +. +# Document + +| Color | +| ------ | +| R [^1] | +| G [^2] | +| B [^3] | + +```{tip} +| Color | +| ------ | +| C [^4] | +| M [^5] | +| Y [^6] | +``` + +[^1]: Red + +[^2]: Green + +[^3]: Blue + +[^4]: Cyan + +[^5]: Magenta + +[^6]: Yellow +. +# Document + +| Color | +| ------ | +| R [^1] | +| G [^2] | +| B [^3] | + +```{tip} +| Color | +| ------ | +| C [^4] | +| M [^5] | +| Y [^6] | +``` + +[^1]: Red + +[^2]: Green + +[^3]: Blue + +[^4]: Cyan + +[^5]: Magenta + +[^6]: Yellow +. + + +Multiple references to same footnote with subIds +. +First [^1] and second [^1] and third [^1] + +[^1]: Shared footnote +. +First [^1] and second [^1] and third [^1] + +[^1]: Shared footnote +. + + +Orphan footnotes (defined but never referenced) +. +Referenced [^used] + +[^orphan]: This is never referenced + +[^used]: This is used + +[^another-orphan]: Also unused +. +Referenced [^used] + +[^used]: This is used +. + + +Chained nested footnote references (A references B, B references C) +. +[^a]: References B [^b] + +[^b]: References C [^c] + +[^c]: Final one + +Start [^a] +. +Start [^a] + +[^b]: References C [^c] + +[^c]: Final one + +[^a]: References B [^b] +. + + +Complex mixed ordering with multiple references +. +[^z]: Defined first + +[^a]: Defined second + +Text [^a] then [^z] then [^a] again +. +Text [^a] then [^z] then [^a] again + +[^a]: Defined second + +[^z]: Defined first +. + + +Footnote referenced in body and within another footnote +. +[^x]: Simple + +[^y]: Contains [^x] reference + +Body [^x] and [^y] +. +Body [^x] and [^y] + +[^x]: Simple + +[^y]: Contains [^x] reference +. + + +Deeply nested: footnote in list in footnote +. +[^outer]: List item: + - Item with [^inner] reference + - Another item + +[^inner]: Inner content + +Text [^outer] +. +Text [^outer] + +[^inner]: Inner content + +[^outer]: List item: + + - Item with [^inner] reference + - Another item +. + + +Multiple footnotes in nested structures +. +[^1]: First + +[^2]: Second with [^1] + +[^3]: Third with [^2] and [^1] + +Body: [^3] [^2] [^1] +. +Body: [^3] [^2] [^1] + +[^1]: First + +[^2]: Second with [^1] + +[^3]: Third with [^2] and [^1] +. + + +Reordering with mixed body and nested references +. +[^c]: Defined first + +[^b]: Defined second [^c] + +[^a]: Defined third [^b] + +Body [^a] [^b] [^c] +. +Body [^a] [^b] [^c] + +[^c]: Defined first + +[^b]: Defined second [^c] + +[^a]: Defined third [^b] +. + + +Footnotes with same reference appearing in body and definitions +. +[^shared]: Base note + +[^wrapper]: Contains [^shared] + +First [^shared] in body, then [^wrapper] +. +First [^shared] in body, then [^wrapper] + +[^shared]: Base note + +[^wrapper]: Contains [^shared] +. + + +Complex scenario: multiple refs, nesting, and reordering +. +[^z]: Last defined [^a] + +[^m]: Middle defined + +[^a]: First defined [^m] + +Body [^m] [^a] [^z] [^m] +. +Body [^m] [^a] [^z] [^m] + +[^a]: First defined [^m] + +[^m]: Middle defined + +[^z]: Last defined [^a] +. + + +Footnote in blockquote with nested reference +. +[^inner]: Inner note + +[^outer]: Quote: + > Blockquote with [^inner] + +Text [^outer] +. +Text [^outer] + +[^inner]: Inner note + +[^outer]: Quote: + + > Blockquote with [^inner] +. + + +Three-level deep nesting +. +[^1]: Level 1 + +[^2]: Level 2 [^1] + +[^3]: Level 3 [^2] + +Start [^3] +. +Start [^3] + +[^1]: Level 1 + +[^2]: Level 2 [^1] + +[^3]: Level 3 [^2] +. + + +Mixed orphans and referenced with complex ordering +. +[^used-first]: Used + +[^orphan-1]: Never used + +[^used-second]: Also used [^used-first] + +[^orphan-2]: Also never used + +Body [^used-second] [^used-first] +. +Body [^used-second] [^used-first] + +[^used-first]: Used + +[^used-second]: Also used [^used-first] +. + + +Footnotes in table cells with cross-references +. +[^1]: First + +[^2]: Second [^1] + +| Col A | Col B | +| ----- | ----- | +| A [^1] | B [^2] | +. +| Col A | Col B | +| ----- | ----- | +| A [^1] | B [^2] | + +[^1]: First + +[^2]: Second [^1] +. + + +Same footnote multiple times in same and different contexts +. +[^repeat]: Repeated note + +Para 1: [^repeat] [^repeat] + +[^nested]: Has [^repeat] inside + +Para 2: [^nested] [^repeat] +. +Para 1: [^repeat] [^repeat] + +Para 2: [^nested] [^repeat] + +[^repeat]: Repeated note + +[^nested]: Has [^repeat] inside +. diff --git a/tests/fixtures/regression.md b/tests/fixtures/regression.md new file mode 100644 index 0000000..ea52cb8 --- /dev/null +++ b/tests/fixtures/regression.md @@ -0,0 +1,129 @@ +Issue 7: footnote ref inside footnote without body reference +. +[^a]: lorem +[^c]: ipsum [^a] +. +[^a]: lorem +. + + +Issue 7: with body reference +. +Body refs [^c] + +[^a]: lorem +[^c]: ipsum [^a] +. +Body refs [^c] + +[^c]: ipsum [^a] + +[^a]: lorem +. + + +Issue 8: nested footnote refs +. +[^a]: Lorem. [^b] + +[^b]: Ipsum. + +A [^b] +. +A [^b] + +[^b]: Ipsum. +. + + +Issue 22: nested in admonition +. +# Document + +| Color | +| ------ | +| R [^1] | +| G [^2] | +| B [^3] | + +```{tip} +| Color | +| ------ | +| C [^4] | +| M [^5] | +| Y [^6] | +``` + +[^1]: Red + +[^2]: Green + +[^3]: Blue + +[^4]: Cyan + +[^5]: Magenta + +[^6]: Yellow +. +# Document + +| Color | +| ------ | +| R [^1] | +| G [^2] | +| B [^3] | + +```{tip} +| Color | +| ------ | +| C [^4] | +| M [^5] | +| Y [^6] | +``` + +[^1]: Red + +[^2]: Green + +[^3]: Blue + +[^4]: Cyan + +[^5]: Magenta + +[^6]: Yellow +. + + +Reference order preserved +. +Text [^b] then [^a] + +[^a]: First +[^b]: Second +. +Text [^b] then [^a] + +[^b]: Second + +[^a]: First +. + + +Chained nested footnotes +. +Start [^a] + +[^a]: References B [^b] +[^b]: References C [^c] +[^c]: Final one +. +Start [^a] + +[^a]: References B [^b] + +[^b]: References C [^c] + +[^c]: Final one +. diff --git a/tests/fixtures_wrap.md b/tests/fixtures/word_wrap.md similarity index 100% rename from tests/fixtures_wrap.md rename to tests/fixtures/word_wrap.md diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py new file mode 100644 index 0000000..0de68f9 --- /dev/null +++ b/tests/test_cli_integration.py @@ -0,0 +1,51 @@ +"""Integration tests for CLI arguments.""" + +from pathlib import Path +import subprocess +import tempfile + +from fixture_helpers import get_fixture + + +def test_cli_keep_orphans_flag(): + """Test --keep-footnote-orphans flag from command line.""" + text, expected_keep = get_fixture( + "cli_integration.md", "CLI keep orphans flag test" + ) + + with tempfile.TemporaryDirectory() as tmpdir: + input_file = Path(tmpdir) / "test.md" + input_file.write_text(text) + + # Default behavior: remove orphans + result = subprocess.run( + ["python", "-m", "mdformat", str(input_file)], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + output_default = input_file.read_text() + assert "[^orphan]" not in output_default + assert "[^used]" in output_default + + # With --keep-footnote-orphans: preserve orphans + input_file.write_text(text) # Reset file + result = subprocess.run( + ["python", "-m", "mdformat", "--keep-footnote-orphans", str(input_file)], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + output_keep = input_file.read_text() + assert output_keep.strip() == expected_keep.strip() + + +def test_cli_help_shows_option(): + """Test that --keep-footnote-orphans appears in help.""" + result = subprocess.run( + ["python", "-m", "mdformat", "--help"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "--keep-footnote-orphans" in result.stdout diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index c68bb16..9e9a26d 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -1,17 +1,45 @@ +"""All fixture-based tests for mdformat-footnote.""" + from pathlib import Path +import re -from markdown_it.utils import read_fixture_file +from fixture_helpers import load_fixtures import mdformat import pytest -FIXTURE_PATH = Path(__file__).parent / "fixtures.md" -fixtures = read_fixture_file(FIXTURE_PATH) + +def _get_options(filename: str, title: str) -> dict: + """Determine mdformat options based on fixture file and title.""" + if filename == "word_wrap.md": + if match := re.search(r"wrap at (\d+)", title): + return {"wrap": int(match.group(1))} + return {"wrap": 40} + if "keep orphans" in title.lower(): + return {"keep_orphans": True} + return {} + + +# Load all fixture files +TEST_CASES: list[tuple[str, str, str, str, str, dict]] = [] +for pth in (Path(__file__).parent / "fixtures").glob("*.md"): + filename = pth.name + for line, title, text, expected in load_fixtures(filename): + options = _get_options(filename, title) + TEST_CASES.append((filename, line, title, text, expected, options)) @pytest.mark.parametrize( - "line,title,text,expected", fixtures, ids=[f[1] for f in fixtures] + "filename,line,title,text,expected,options", + TEST_CASES, + ids=[f"{tc[0].replace('.md', '')}::{tc[2]}" for tc in TEST_CASES], ) -def test_fixtures(line, title, text, expected): - output = mdformat.text(text, extensions={"footnote"}) - print(output) +def test_fixtures( + filename: str, + line: int, + title: str, + text: str, + expected: str, + options: dict, +): + output = mdformat.text(text, extensions={"footnote"}, options=options) assert output.rstrip() == expected.rstrip(), output diff --git a/tests/test_word_wrap.py b/tests/test_word_wrap.py deleted file mode 100644 index cf871b5..0000000 --- a/tests/test_word_wrap.py +++ /dev/null @@ -1,26 +0,0 @@ -from pathlib import Path -import re - -from markdown_it.utils import read_fixture_file -import mdformat -import pytest - -FIXTURE_PATH = Path(__file__).parent / "fixtures_wrap.md" -fixtures = read_fixture_file(FIXTURE_PATH) - - -def _extract_wrap_length(title): - if match := re.search(r"wrap at (\d+)", title): - return int(match.group(1)) - return 40 - - -@pytest.mark.parametrize( - "line,title,text,expected", - fixtures, - ids=[f[1] for f in fixtures], -) -def test_word_wrap(line, title, text, expected): - wrap_length = _extract_wrap_length(title) - output = mdformat.text(text, options={"wrap": wrap_length}, extensions={"footnote"}) - assert output.rstrip() == expected.rstrip(), output