pyDiffTools 0.1.38__tar.gz → 0.1.40__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {pydifftools-0.1.38/pyDiffTools.egg-info → pydifftools-0.1.40}/PKG-INFO +1 -1
  2. {pydifftools-0.1.38 → pydifftools-0.1.40/pyDiffTools.egg-info}/PKG-INFO +1 -1
  3. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/wrap_sentences.py +140 -39
  4. {pydifftools-0.1.38 → pydifftools-0.1.40}/pyproject.toml +1 -1
  5. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_wrap_check.py +133 -0
  6. {pydifftools-0.1.38 → pydifftools-0.1.40}/LICENSE.md +0 -0
  7. {pydifftools-0.1.38 → pydifftools-0.1.40}/MANIFEST.in +0 -0
  8. {pydifftools-0.1.38 → pydifftools-0.1.40}/README.rst +0 -0
  9. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/_quarto.yml +0 -0
  10. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  11. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  12. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  13. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  14. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  15. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  16. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  17. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  18. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  19. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  20. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  21. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  22. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  23. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  24. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  25. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  26. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  27. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  28. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  29. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  30. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  31. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  32. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/example.qmd +0 -0
  33. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/example.tex +0 -0
  34. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/index.qmd +0 -0
  35. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  36. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  37. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  38. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  39. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/subproject1/independent.qmd +0 -0
  40. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/subproject1/index.qmd +0 -0
  41. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  42. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  43. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  44. {pydifftools-0.1.38 → pydifftools-0.1.40}/example_notebook/project1/test_include.qmd +0 -0
  45. {pydifftools-0.1.38 → pydifftools-0.1.40}/pyDiffTools.egg-info/SOURCES.txt +0 -0
  46. {pydifftools-0.1.38 → pydifftools-0.1.40}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  47. {pydifftools-0.1.38 → pydifftools-0.1.40}/pyDiffTools.egg-info/entry_points.txt +0 -0
  48. {pydifftools-0.1.38 → pydifftools-0.1.40}/pyDiffTools.egg-info/requires.txt +0 -0
  49. {pydifftools-0.1.38 → pydifftools-0.1.40}/pyDiffTools.egg-info/top_level.txt +0 -0
  50. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/__init__.py +0 -0
  51. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/bibliography.py +0 -0
  52. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/browser_lifecycle.py +0 -0
  53. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/check_numbers.py +0 -0
  54. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/citation_dialog.py +0 -0
  55. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/command_line.py +0 -0
  56. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/command_registry.py +0 -0
  57. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/comment_functions.py +0 -0
  58. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/comment_migration.py +0 -0
  59. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/comment_tags.lua +0 -0
  60. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/comment_tags_margin.lua +0 -0
  61. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/comment_tags_no_comments.lua +0 -0
  62. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/comment_toggle.js +0 -0
  63. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/comments.css +0 -0
  64. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/continuous.py +0 -0
  65. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/copy_files.py +0 -0
  66. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/diff-doc.js +0 -0
  67. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/doc_contents.py +0 -0
  68. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/flowchart/__init__.py +0 -0
  69. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/flowchart/comparison.py +0 -0
  70. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  71. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/flowchart/graph.py +0 -0
  72. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/flowchart/svg-pan-zoom.min.js +0 -0
  73. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/flowchart/watch_graph.py +0 -0
  74. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/flowchart/wgrph_view.js +0 -0
  75. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/forward_search.py +0 -0
  76. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/git_gd.py +0 -0
  77. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/git_gd_history.py +0 -0
  78. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/git_gd_image.py +0 -0
  79. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/git_gd_qt.py +0 -0
  80. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/html_comments.py +0 -0
  81. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/html_uncomments.py +0 -0
  82. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/log_example.py +0 -0
  83. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/match_spaces.py +0 -0
  84. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/notebook/__init__.py +0 -0
  85. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/notebook/display.py +0 -0
  86. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/notebook/fast_build.py +0 -0
  87. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/notebook/tex_to_qmd.py +0 -0
  88. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/onewordify.py +0 -0
  89. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/onewordify_undo.py +0 -0
  90. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/outline.py +0 -0
  91. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/rearrange_tex.py +0 -0
  92. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/searchacro.py +0 -0
  93. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/separate_comments.py +0 -0
  94. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/split_conflict.py +0 -0
  95. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/unseparate_comments.py +0 -0
  96. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/update_check.py +0 -0
  97. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/xml2xlsx.vbs +0 -0
  98. {pydifftools-0.1.38 → pydifftools-0.1.40}/pydifftools/zotero.py +0 -0
  99. {pydifftools-0.1.38 → pydifftools-0.1.40}/setup.cfg +0 -0
  100. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_bibliography.py +0 -0
  101. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_browser_lifecycle.py +0 -0
  102. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_check_single_use.py +0 -0
  103. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_command_line_help.py +0 -0
  104. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_comment_migration.py +0 -0
  105. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_continuous_shutdown.py +0 -0
  106. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_forward_search.py +0 -0
  107. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_git_gd_history.py +0 -0
  108. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_git_gd_image.py +0 -0
  109. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_git_gd_qt.py +0 -0
  110. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_rrng.py +0 -0
  111. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_tex_to_qmd.py +0 -0
  112. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_update_check.py +0 -0
  113. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_wrapping.py +0 -0
  114. {pydifftools-0.1.38 → pydifftools-0.1.40}/tests/test_zotero.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.38
3
+ Version: 0.1.40
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.38
3
+ Version: 0.1.40
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -16,6 +16,16 @@ PARAGRAPH_SPLIT = re.compile(r"(\n(?:[ \t]*\n)+)")
16
16
  SENTENCE_END = r"[^.!?]{3}[.!?]"
17
17
  SENTENCE_SPLIT = re.compile("(" + SENTENCE_END + r")[ \n]")
18
18
  SENTENCE_BOUNDARY = re.compile("(" + SENTENCE_END + r")([ \n])")
19
+ # A word is a run of non-space characters, except that an attribute block
20
+ # attached to a figure's closing ")" (or any "{#id ...}" block) stays inside
21
+ # the word, so a break never lands inside a pandoc-crossref marker.
22
+ WORD = re.compile(r"(?:(?<=\))\{[^}]*\}|\{#[^}]*\}|\S)+")
23
+ # A pandoc-crossref marker must directly follow the closing "$$" of its
24
+ # equation or the closing ")" of its figure, and must stay on one line.
25
+ DETACHED_CROSSREF = re.compile(
26
+ r"(?:(?<!\\)\$\$|\))(?P<gap>[ \t]*\n?[ \t]*)(?=\{#(?:eq|fig):)"
27
+ r"|(?P<marker>\{#(?:eq|fig):[^}\n]*\n[^}]*\})"
28
+ )
19
29
  LATEX_MACRO_SPLIT = re.compile(
20
30
  r"(\\(?:begin|end|usepackage|newcommand|section"
21
31
  r"|subsection|subsubsection|paragraph|input){[^}]*})"
@@ -584,6 +594,11 @@ def classify_lines(
584
594
  if not equation.endswith("\n"):
585
595
  equation += "\n"
586
596
  equation += "$$"
597
+ # keep a pandoc-crossref marker on its closing "$$"
598
+ marker = re.match(r"\{#eq:[^}]*\}", content[stop:])
599
+ if marker:
600
+ equation += marker[0]
601
+ stop += marker.end()
587
602
  if stop < len(content):
588
603
  equation += "\n"
589
604
  if content[stop] == "\n":
@@ -686,7 +701,9 @@ def wrap_prose(
686
701
  lines = []
687
702
  indentation = 0
688
703
  for sentence in sentences:
689
- words = [word for word in re.split("[ \n]+", sentence) if word]
704
+ words = [
705
+ re.sub(r"\s+", " ", word) for word in WORD.findall(sentence)
706
+ ]
690
707
  boundaries = _math_boundaries(words)
691
708
  if filetype == "latex":
692
709
  indentation = 0
@@ -794,6 +811,15 @@ def _prepare(filename, cleanoo=False, i=-1):
794
811
  def wr(filename, wrapnumber=45, punctuation_slop=20, cleanoo=False, i=-1):
795
812
  alltext, filetype, indent_amount, _ = _prepare(filename, cleanoo, i)
796
813
  if filetype == "markdown":
814
+ # reattach detached pandoc-crossref markers before wrapping
815
+ alltext = DETACHED_CROSSREF.sub(
816
+ lambda found: (
817
+ re.sub(r"\s+", " ", found["marker"])
818
+ if found["marker"]
819
+ else found[0][: len(found[0]) - len(found["gap"])]
820
+ ),
821
+ alltext,
822
+ )
797
823
  result = wrap_blocks(
798
824
  markdown_blocks(alltext),
799
825
  wrapnumber,
@@ -865,7 +891,7 @@ def check_prose(content, wrapnumber, punctuation_slop, line_start=1):
865
891
  if fragment:
866
892
  # {{{ check line lengths within this sentence fragment
867
893
  words, positions = [], []
868
- for match in re.finditer(r"\S+", fragment):
894
+ for match in WORD.finditer(fragment):
869
895
  words.append(match.group())
870
896
  positions.append(frag_offset + match.start())
871
897
  if words:
@@ -942,7 +968,9 @@ def _iter_classified_spans(text, filetype, line_start=1):
942
968
  leaf prose blocks -- both just need the same protected-region
943
969
  exclusions wr itself uses.
944
970
  """
945
- classified = classify_lines(text, filetype, normalize_math=True)
971
+ # normalizing math can add lines (e.g. splitting "$${#eq:x}"), which
972
+ # would shift every reported line number after it
973
+ classified = classify_lines(text, filetype)
946
974
  line = line_start
947
975
  for wrappable, group in itertools.groupby(classified, key=lambda x: x[0]):
948
976
  content = "".join(l for _, l in group)
@@ -985,6 +1013,9 @@ def markdown_lint_issues_from_text(
985
1013
  prose_content = re.sub(
986
1014
  r"<!--.*?(?:-->|\Z)", mask_comment, content, flags=re.DOTALL
987
1015
  )
1016
+ # trailing spaces are reported separately below, and a double space
1017
+ # (a hard line break) must not look like a sentence ending mid-line
1018
+ prose_content = re.sub(r"[ \t]+$", "", prose_content, flags=re.M)
988
1019
  issues = [
989
1020
  issue
990
1021
  for line, width, span in _iter_block_spans(
@@ -996,6 +1027,27 @@ def markdown_lint_issues_from_text(
996
1027
  (issue["line"], issue["message"])
997
1028
  for issue in unclosed_markdown_spans(content)
998
1029
  )
1030
+ # {{{ report single trailing spaces and detached crossref markers
1031
+ fence = None
1032
+ for number, line in enumerate(content.splitlines(), start=1):
1033
+ opening = re.match(r"[ \t]*(\x60{3,}|~{3,})", line)
1034
+ if opening and (fence is None or opening[1].startswith(fence)):
1035
+ fence = None if fence else opening[1]
1036
+ elif fence is None and re.search(r"(?<! ) $", line):
1037
+ issues.append((
1038
+ number,
1039
+ "trailing space: remove the single space at the end of this "
1040
+ "line (two spaces are a deliberate hard line break)",
1041
+ ))
1042
+ for found in DETACHED_CROSSREF.finditer(prose_content):
1043
+ if found["gap"] == "":
1044
+ continue
1045
+ issues.append((
1046
+ prose_content.count("\n", 0, found.start()) + 1,
1047
+ "detached crossref marker: keep the {#eq:...} or {#fig:...} "
1048
+ "marker on one line, directly after the closing $$ or )",
1049
+ ))
1050
+ # }}}
999
1051
  return sorted(issues)
1000
1052
 
1001
1053
 
@@ -1099,6 +1151,33 @@ def apply_markdown_issue_fix(content, line_number, message):
1099
1151
  raise ValueError(
1100
1152
  f"Markdown issue refers to missing line {line_number}"
1101
1153
  )
1154
+ if message.startswith("detached crossref marker"):
1155
+ # {{{ join the marker onto its "$$" or ")" (this can remove a line)
1156
+ start = sum(len(line) for line in lines[:index])
1157
+ found = next(
1158
+ found
1159
+ for found in DETACHED_CROSSREF.finditer(content, start)
1160
+ if found["gap"] != ""
1161
+ )
1162
+ if found["gap"] is None:
1163
+ span = found.span("marker")
1164
+ joined = re.sub(r"\s+", " ", found["marker"])
1165
+ else:
1166
+ span = found.span("gap")
1167
+ joined = ""
1168
+ before = content[found.start() : found.end()]
1169
+ updated = content[: span[0]] + joined + content[span[1] :]
1170
+ after = updated[
1171
+ found.start() : span[0] + len(joined) + found.end() - span[1]
1172
+ ]
1173
+ return (
1174
+ updated,
1175
+ before,
1176
+ after,
1177
+ "A crossref marker was separated from its equation or figure. "
1178
+ "I joined it back on.",
1179
+ )
1180
+ # }}}
1102
1181
  old_line = lines[index]
1103
1182
  ending = ""
1104
1183
  if old_line.endswith("\r\n"):
@@ -1121,20 +1200,33 @@ def apply_markdown_issue_fix(content, line_number, message):
1121
1200
  else:
1122
1201
  prefix = re.match(r"^[ \t]*", old_line).group()
1123
1202
  # }}}
1203
+ if message.startswith("trailing space:"):
1204
+ lines[index] = old_line[:-1] + ending
1205
+ return (
1206
+ "".join(lines),
1207
+ old_line,
1208
+ old_line[:-1],
1209
+ "A line ended with a single stray space. I removed it.",
1210
+ )
1124
1211
  if message.startswith("line too long:"):
1125
- match = re.search(r"before '([^']+)'", message)
1212
+ match = re.search(r"after '(.+?)' \(before '(.+?)'\); move", message)
1126
1213
  if match is None:
1127
1214
  raise ValueError(
1128
1215
  f"Cannot find the suggested word in {message!r}"
1129
1216
  )
1130
- word = re.escape(match.group(1))
1131
- position = re.search(r"(?<!\S)" + word + r"(?!\S)", old_line)
1217
+ # split at the suggested word that follows wr's break word, since the
1218
+ # same word can appear earlier on the line (e.g. "for ... ($3x$ for")
1219
+ position = re.search(
1220
+ r"(?<!\S)" + re.escape(match.group(1)) + r"[ \t]+("
1221
+ + re.escape(match.group(2)) + r")(?!\S)",
1222
+ old_line,
1223
+ )
1132
1224
  if position is None:
1133
1225
  raise ValueError(
1134
- f"Cannot find {match.group(1)!r} on Markdown line "
1135
- f"{line_number}"
1226
+ f"Cannot find {match.group(2)!r} after {match.group(1)!r} on "
1227
+ f"Markdown line {line_number}"
1136
1228
  )
1137
- split_at = position.start()
1229
+ split_at = position.start(1)
1138
1230
  first = old_line[:split_at].rstrip()
1139
1231
  second = old_line[split_at:].lstrip(" \t")
1140
1232
  reason = (
@@ -1157,8 +1249,12 @@ def apply_markdown_issue_fix(content, line_number, message):
1157
1249
  else:
1158
1250
  raise ValueError(f"No automatic fix is available for {message!r}")
1159
1251
 
1160
- fixed_line = first + ending + prefix + second + ending
1161
- lines[index] = fixed_line
1252
+ if not first.strip() or not second.strip():
1253
+ # a break here would insert a blank line, i.e. a paragraph break
1254
+ raise ValueError(
1255
+ f"Automatic fix would leave an empty line at {line_number}"
1256
+ )
1257
+ lines[index] = first + ending + prefix + second + ending
1162
1258
  after = first + "\n" + prefix + second
1163
1259
  return "".join(lines), old_line, after, reason
1164
1260
 
@@ -1168,39 +1264,44 @@ def autofix_markdown_file(filename, wrapnumber=55, punctuation_slop=20):
1168
1264
  with open(filename, encoding="utf-8", newline="") as fp:
1169
1265
  content = fp.read()
1170
1266
  fixes = []
1171
- for _ in range(1000):
1267
+ # Lint once, fix every flagged line, then lint again, since fixing one
1268
+ # issue at a time re-lints the whole file for every single fix.
1269
+ for _ in range(100):
1172
1270
  issues = markdown_lint_issues_from_text(
1173
1271
  content, wrapnumber=wrapnumber, punctuation_slop=punctuation_slop
1174
1272
  )
1175
- fixable = next(
1176
- (
1177
- issue
1178
- for issue in issues
1179
- if not issue[1].startswith(
1180
- ("unclosed math:", "unclosed HTML comment:")
1181
- )
1182
- ),
1183
- None,
1184
- )
1185
- if fixable is None:
1273
+ fixable = {}
1274
+ for line_number, message in issues:
1275
+ if not message.startswith(
1276
+ ("unclosed math:", "unclosed HTML comment:")
1277
+ ):
1278
+ # a fix only rewrites its own line, so fix one issue per line
1279
+ # per pass and let the next lint catch whatever remains
1280
+ fixable.setdefault(line_number, message)
1281
+ if not fixable:
1186
1282
  break
1187
- line_number, message = fixable
1188
- updated, before, after, reason = apply_markdown_issue_fix(
1189
- content, line_number, message
1190
- )
1191
- if updated == content:
1192
- raise RuntimeError(
1193
- f"Automatic Markdown fix made no change at line {line_number}"
1283
+ # work from the bottom up so each inserted line break leaves the
1284
+ # line numbers of the remaining issues unchanged
1285
+ pass_fixes = []
1286
+ for line_number in sorted(fixable, reverse=True):
1287
+ updated, before, after, reason = apply_markdown_issue_fix(
1288
+ content, line_number, fixable[line_number]
1194
1289
  )
1195
- fixes.append(
1196
- {
1197
- "line": line_number,
1198
- "reason": reason,
1199
- "before": before,
1200
- "after": after,
1201
- }
1202
- )
1203
- content = updated
1290
+ if updated == content:
1291
+ raise RuntimeError(
1292
+ "Automatic Markdown fix made no change at line "
1293
+ f"{line_number}"
1294
+ )
1295
+ pass_fixes.append(
1296
+ {
1297
+ "line": line_number,
1298
+ "reason": reason,
1299
+ "before": before,
1300
+ "after": after,
1301
+ }
1302
+ )
1303
+ content = updated
1304
+ fixes.extend(reversed(pass_fixes))
1204
1305
  else:
1205
1306
  raise RuntimeError("Too many automatic Markdown fixes were needed.")
1206
1307
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyDiffTools"
7
- version = "0.1.38"
7
+ version = "0.1.40"
8
8
  authors = [{ name = "J M Franck" }]
9
9
  description = "Diff tools"
10
10
  readme = "README.rst"
@@ -1,3 +1,4 @@
1
+ import re
1
2
  import subprocess
2
3
 
3
4
  import pytest
@@ -120,6 +121,138 @@ def test_one_reported_long_line_is_fixed_at_the_suggested_word():
120
121
  assert "hard to read" in reason
121
122
 
122
123
 
124
+ def test_lint_line_numbers_survive_labeled_display_math():
125
+ # normalizing "$${#eq:x}" for wrapping splits it onto two lines, which
126
+ # must not shift the line numbers reported for later prose
127
+ source = (
128
+ "Intro.\n$$\nx = y\n$${#eq:x}\nwhere this sentence ends. "
129
+ "Another starts.\n"
130
+ )
131
+
132
+ ((line, message),) = markdown_lint_issues_from_text(source, 55)
133
+
134
+ assert line == 5
135
+ assert message.startswith("sentence ends mid-line")
136
+
137
+
138
+ def test_long_line_fix_splits_at_the_word_after_the_break():
139
+ # "for" also starts the line, but wr breaks before the second one
140
+ source = (
141
+ "for $30\\;\\text{s}$ ($3\\times$ for AOT) with some more words "
142
+ "added.\n"
143
+ )
144
+ message = (
145
+ "line too long: wr would break after '($3\\times$' (before 'for'); "
146
+ "move 'for' onward to the next line, or shorten this sentence"
147
+ )
148
+
149
+ fixed, _, _, _ = apply_markdown_issue_fix(source, 1, message)
150
+
151
+ assert fixed.startswith("for $30\\;\\text{s}$ ($3\\times$\nfor AOT)")
152
+
153
+
154
+ def test_single_trailing_space_is_flagged_and_removed(tmp_path):
155
+ # from RM_ESR: a sentence ending in a stray space
156
+ path = tmp_path / "source.md"
157
+ path.write_text("It melts near 238&nbsp;K. \nIn contrast, it stays.\n")
158
+
159
+ issues = markdown_lint_issues_from_text(path.read_text(), wrapnumber=55)
160
+ autofix_markdown_file(path)
161
+
162
+ assert (1, "trailing space") in [
163
+ (line, message.split(":")[0]) for line, message in issues
164
+ ]
165
+ fixed = path.read_text()
166
+ assert fixed.startswith("It melts near 238&nbsp;K.\nIn contrast,")
167
+ assert " \n" not in fixed
168
+
169
+
170
+ def test_double_trailing_space_hard_break_is_allowed():
171
+ source = "The first line ends here with a break \nand continues.\n"
172
+
173
+ assert markdown_lint_issues_from_text(source, wrapnumber=55) == []
174
+
175
+
176
+ @pytest.mark.parametrize(
177
+ "detached",
178
+ [
179
+ # equation markers must sit directly on the closing $$
180
+ "Intro.\n$$\nx = y\n$$ {#eq:x}\nwhere this holds.\n",
181
+ "Intro.\n$$\nx = y\n$$\n{#eq:x}\nwhere this holds.\n",
182
+ # figure markers must sit directly on the closing ) (from eigenmode)
183
+ "![A caption.](./media/BothProbes_Bfields.png)\n"
184
+ '{#fig:ProbeMagFieldsDrawing width="4in"}\n',
185
+ # and must not be split across lines (from RM_ESR)
186
+ "![A caption for\nspectroscopy.](Figures/simple_oned_CAT16.png)"
187
+ "{#fig:justSpectraCat16\nwidth=4in}\n",
188
+ ],
189
+ )
190
+ def test_detached_crossref_marker_is_flagged_and_joined(tmp_path, detached):
191
+ path = tmp_path / "source.md"
192
+ path.write_text(detached)
193
+
194
+ issues = markdown_lint_issues_from_text(detached, wrapnumber=55)
195
+ report = autofix_markdown_file(path)
196
+
197
+ fixed = path.read_text()
198
+ assert [message.split(":")[0] for _, message in issues] == [
199
+ "detached crossref marker"
200
+ ]
201
+ assert report["fixes"]
202
+ assert markdown_lint_issues_from_text(fixed, wrapnumber=55) == []
203
+ assert re.search(r"(\$\$|\))\{#(eq|fig):[^}\n]*\}", fixed)
204
+
205
+
206
+ def test_figure_crossref_marker_is_never_split_from_its_figure(tmp_path):
207
+ # from RM_ESR: a caption spanning lines, ending in a marker with
208
+ # attributes; wr and the linter must treat "...){#fig:... }" as one word
209
+ source = (
210
+ "![Room temperature ESR of 100 mM TEMPO-SO~4~ RMs with water "
211
+ "loading $w_0=30$ in isooctane dispersant (blue), single HE "
212
+ "simulation (orange), multi HE simulation "
213
+ "(green)](Figures/HE_Easyspin_Simul_100mM.png)\n"
214
+ "{#fig:HESimul width=5.5in}\n"
215
+ )
216
+ path = tmp_path / "source.md"
217
+ path.write_text(source)
218
+
219
+ wr(str(path), 55)
220
+
221
+ wrapped = path.read_text()
222
+ assert (
223
+ "(green)](Figures/HE_Easyspin_Simul_100mM.png)"
224
+ "{#fig:HESimul width=5.5in}\n"
225
+ ) in wrapped
226
+ assert markdown_lint_issues_from_text(wrapped, wrapnumber=55) == []
227
+
228
+
229
+ def test_wr_keeps_equation_marker_on_closing_dollars(tmp_path):
230
+ path = tmp_path / "source.md"
231
+ path.write_text(
232
+ "Intro.\n$$\nx = y\n$$ {#eq:tauFromLinewidth}\nwhere this holds.\n"
233
+ )
234
+
235
+ wr(str(path), 55)
236
+
237
+ wrapped = path.read_text()
238
+ assert "\n$${#eq:tauFromLinewidth}\n" in wrapped
239
+ assert markdown_lint_issues_from_text(wrapped, wrapnumber=55) == []
240
+
241
+
242
+ def test_autofix_fixes_every_line_in_one_pass_from_the_end(tmp_path):
243
+ path = tmp_path / "source.md"
244
+ paragraph = "First sentence ends here. A second sentence begins.\n"
245
+ path.write_text("\n".join([paragraph] * 30))
246
+
247
+ report = autofix_markdown_file(path)
248
+
249
+ fixed = path.read_text()
250
+ assert len(report["fixes"]) == 30
251
+ assert [fix["line"] for fix in report["fixes"]] == list(range(1, 60, 2))
252
+ assert fixed.count("\n\n") == 29
253
+ assert markdown_lint_issues_from_text(fixed, wrapnumber=55) == []
254
+
255
+
123
256
  def test_unclosed_math_is_reported_after_fifty_lines():
124
257
  short_math = "$$\n" + ("x = y\n" * 48)
125
258
  long_math = "$$\n" + ("x = y\n" * 49)
File without changes
File without changes
File without changes
File without changes