Repository: platers/obsidian-linter
Pull request #327 (2022-07-31 21:05:53)

Try to Ensure Footnotes Show Up in Proper Order
When a footnote is inserted in a document where the footnotes were already moved to the end of the file, the values for the footnotes were not being moved to the end and associated with the proper reference. As a result, the logic has been updated to make it so that it will try to find the first instance of an unused footnote reference that matches the one in question and use that character index when sorting.

Changes Made:
- Footnotes must now have an associated reference or else an error will occur when it tries to move the value to the end
- Updated tests to account for new sorting and added the test for a modified version of #314 


Patch:
diff --git a/package-lock.json b/package-lock.json
index fcfbf8f..19e7f34 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,13 +13,13 @@
         "diff-match-patch": "^1.0.5",
         "js-yaml": "^4.1.0",
         "loglevel": "^1.8.0",
+        "mdast-util-gfm": "^2.0.1",
+        "micromark-extension-gfm": "^2.0.1",
         "moment": "^2.29.4",
-        "remark": "^14.0.2",
-        "remark-gfm": "^3.0.1",
         "rollup-plugin-terser": "^7.0.2",
         "ts-dedent": "^2.2.0",
-        "unified": "^10.1.2",
-        "unified-lint-rule": "^2.1.0"
+        "unified-lint-rule": "^2.1.1",
+        "unist-util-visit": "^4.1.0"
       },
       "devDependencies": {
         "@babel/core": "^7.18.9",
@@ -7094,64 +7094,6 @@
         "jsesc": "bin/jsesc"
       }
     },
-    "node_modules/remark": {
-      "version": "14.0.2",
-      "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.2.tgz",
-      "integrity": "sha512-A3ARm2V4BgiRXaUo5K0dRvJ1lbogrbXnhkJRmD0yw092/Yl0kOCZt1k9ZeElEwkZsWGsMumz6qL5MfNJH9nOBA==",
-      "dependencies": {
-        "@types/mdast": "^3.0.0",
-        "remark-parse": "^10.0.0",
-        "remark-stringify": "^10.0.0",
-        "unified": "^10.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
-    "node_modules/remark-gfm": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.1.tgz",
-      "integrity": "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==",
-      "dependencies": {
-        "@types/mdast": "^3.0.0",
-        "mdast-util-gfm": "^2.0.0",
-        "micromark-extension-gfm": "^2.0.0",
-        "unified": "^10.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
-    "node_modules/remark-parse": {
-      "version": "10.0.1",
-      "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.1.tgz",
-      "integrity": "sha512-1fUyHr2jLsVOkhbvPRBJ5zTKZZyD6yZzYaWCS6BPBdQ8vEMBCH+9zNCDA6tET/zHCi/jLqjCWtlJZUPk+DbnFw==",
-      "dependencies": {
-        "@types/mdast": "^3.0.0",
-        "mdast-util-from-markdown": "^1.0.0",
-        "unified": "^10.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
-    "node_modules/remark-stringify": {
-      "version": "10.0.2",
-      "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-10.0.2.tgz",
-      "integrity": "sha512-6wV3pvbPvHkbNnWB0wdDvVFHOe1hBRAx1Q/5g/EpH4RppAII6J8Gnwe7VbHuXaoKIF6LAg6ExTel/+kNqSQ7lw==",
-      "dependencies": {
-        "@types/mdast": "^3.0.0",
-        "mdast-util-to-markdown": "^1.0.0",
-        "unified": "^10.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
     "node_modules/require-directory": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -7889,9 +7831,9 @@
       }
     },
     "node_modules/unified": {
-      "version": "10.1.2",
-      "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz",
-      "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==",
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.0.tgz",
+      "integrity": "sha512-4U3ru/BRXYYhKbwXV6lU6bufLikoAavTwev89H5UxY8enDFaAT2VXmIXYNm6hb5oHPng/EXr77PVyDFcptbk5g==",
       "dependencies": {
         "@types/unist": "^2.0.0",
         "bail": "^2.0.0",
@@ -13414,48 +13356,6 @@
         }
       }
     },
-    "remark": {
-      "version": "14.0.2",
-      "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.2.tgz",
-      "integrity": "sha512-A3ARm2V4BgiRXaUo5K0dRvJ1lbogrbXnhkJRmD0yw092/Yl0kOCZt1k9ZeElEwkZsWGsMumz6qL5MfNJH9nOBA==",
-      "requires": {
-        "@types/mdast": "^3.0.0",
-        "remark-parse": "^10.0.0",
-        "remark-stringify": "^10.0.0",
-        "unified": "^10.0.0"
-      }
-    },
-    "remark-gfm": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.1.tgz",
-      "integrity": "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==",
-      "requires": {
-        "@types/mdast": "^3.0.0",
-        "mdast-util-gfm": "^2.0.0",
-        "micromark-extension-gfm": "^2.0.0",
-        "unified": "^10.0.0"
-      }
-    },
-    "remark-parse": {
-      "version": "10.0.1",
-      "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.1.tgz",
-      "integrity": "sha512-1fUyHr2jLsVOkhbvPRBJ5zTKZZyD6yZzYaWCS6BPBdQ8vEMBCH+9zNCDA6tET/zHCi/jLqjCWtlJZUPk+DbnFw==",
-      "requires": {
-        "@types/mdast": "^3.0.0",
-        "mdast-util-from-markdown": "^1.0.0",
-        "unified": "^10.0.0"
-      }
-    },
-    "remark-stringify": {
-      "version": "10.0.2",
-      "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-10.0.2.tgz",
-      "integrity": "sha512-6wV3pvbPvHkbNnWB0wdDvVFHOe1hBRAx1Q/5g/EpH4RppAII6J8Gnwe7VbHuXaoKIF6LAg6ExTel/+kNqSQ7lw==",
-      "requires": {
-        "@types/mdast": "^3.0.0",
-        "mdast-util-to-markdown": "^1.0.0",
-        "unified": "^10.0.0"
-      }
-    },
     "require-directory": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -14006,9 +13906,9 @@
       "dev": true
     },
     "unified": {
-      "version": "10.1.2",
-      "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz",
-      "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==",
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.0.tgz",
+      "integrity": "sha512-4U3ru/BRXYYhKbwXV6lU6bufLikoAavTwev89H5UxY8enDFaAT2VXmIXYNm6hb5oHPng/EXr77PVyDFcptbk5g==",
       "requires": {
         "@types/unist": "^2.0.0",
         "bail": "^2.0.0",
diff --git a/package.json b/package.json
index 1947311..c2de36d 100644
--- a/package.json
+++ b/package.json
@@ -49,11 +49,12 @@
     "diff-match-patch": "^1.0.5",
     "js-yaml": "^4.1.0",
     "loglevel": "^1.8.0",
-    "remark": "^14.0.2",
-    "remark-gfm": "^3.0.1",
-    "unified": "^10.1.2",
-    "unified-lint-rule": "^2.1.0",
+    "mdast-util-gfm": "^2.0.1",
+    "micromark-extension-gfm": "^2.0.1",
     "moment": "^2.29.4",
-    "ts-dedent": "^2.2.0"
+    "rollup-plugin-terser": "^7.0.2",
+    "ts-dedent": "^2.2.0",
+    "unified-lint-rule": "^2.1.1",
+    "unist-util-visit": "^4.1.0"
   }
 }
diff --git a/src/main.ts b/src/main.ts
index fe7b10f..b5bee89 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -14,7 +14,6 @@ import YamlTimestamp from './rules/yaml-timestamp';
 import YamlKeySort from './rules/yaml-key-sort';
 import {RuleBuilderBase} from './rules/rule-builder';
 import {iconInfo} from './icons';
-import {YAMLException} from 'js-yaml';
 
 declare global {
   // eslint-disable-next-line no-unused-vars
@@ -310,7 +309,13 @@ export default class LinterPlugin extends Plugin {
         try {
           await this.runLinterFile(file);
         } catch (error) {
-          this.handleLintError(file, error, 'There is an error in the yaml of file \'${file.path}\': ', 'Lint All Files Error in File \'${file.path}\'');
+          if (error.name === 'YAMLException') {
+            new Notice(`There is an error in file "${file.path}" in the YAML ` + error.mark);
+          } else {
+            new Notice('An error occurred during linting. See console for details');
+          }
+
+          logError(`Lint All Files Error in File '${file.path}'`, error);
 
           numberOfErrors+=1;
         }
@@ -335,7 +340,13 @@ export default class LinterPlugin extends Plugin {
         try {
           await this.runLinterFile(file);
         } catch (error) {
-          this.handleLintError(file, error, 'There is an error in the yaml of file \'${file.path}\': ', 'Lint All Files in Folder Error in File \'${file.path}\'');
+          if (error.name === 'YAMLException') {
+            new Notice(`There is an error in file "${file.path}" in the YAML ` + error.mark);
+          } else {
+            new Notice('An error occurred during linting. See console for details');
+          }
+
+          logError(`Lint All Files in Folder Error in File '${file.path}'`, error);
 
           numberOfErrors+=1;
         }
@@ -368,7 +379,13 @@ export default class LinterPlugin extends Plugin {
     try {
       newText = this.lintText(oldText, file);
     } catch (error) {
-      this.handleLintError(file, error, 'There is an error in the yaml: ', 'Lint File Error in File \'${file.path}\'');
+      if (error.name === 'YAMLException') {
+        new Notice('There is an error in the YAML ' + error.mark);
+      } else {
+        new Notice('An error occurred during linting. See console for details');
+      }
+
+      logError(`Lint File Error in File '${file.path}'`, error);
     }
 
     // Replace changed lines
@@ -429,19 +446,6 @@ export default class LinterPlugin extends Plugin {
       new Notice(message);
     }
   }
-
-  private handleLintError(file: TFile, error: Error, yamlErrorStringTemplate: string, logErrorStringTemplate: string) {
-    if (error instanceof YAMLException) {
-      let errorMessage = error.toString();
-      errorMessage = errorMessage.substring(errorMessage.indexOf(':') + 1);
-
-      new Notice(yamlErrorStringTemplate.replace('${file.path}', file.path) + errorMessage);
-    } else {
-      new Notice('An error occurred during linting. See console for details');
-    }
-
-    logError(logErrorStringTemplate.replace('${file.path}', file.path), error);
-  }
 }
 
 class SettingTab extends PluginSettingTab {
diff --git a/src/rules/re-index-footnotes.ts b/src/rules/re-index-footnotes.ts
index 22d57d0..c4d7e9f 100644
--- a/src/rules/re-index-footnotes.ts
+++ b/src/rules/re-index-footnotes.ts
@@ -15,7 +15,7 @@ export default class ReIndexFootnotes extends RuleBuilder<ReIndexFootnotesOption
     return 'Re-Index Footnotes';
   }
   get description(): string {
-    return 'Re-indexes footnote keys and footnote, based on the order of occurence (NOTE: This rule deliberately does *not* preserve the relation between key and footnote, to be able to re-index duplicate keys.)';
+    return 'Re-indexes footnote keys and footnote, based on the order of occurrence (NOTE: This rule deliberately does *not* preserve the relation between key and footnote, to be able to re-index duplicate keys.)';
   }
   get type(): RuleType {
     return RuleType.FOOTNOTE;
diff --git a/src/utils/mdast.ts b/src/utils/mdast.ts
index 6c484e6..eda305a 100644
--- a/src/utils/mdast.ts
+++ b/src/utils/mdast.ts
@@ -1,10 +1,11 @@
 import {visit} from 'unist-util-visit';
 import type {Position} from 'unist';
 import type {Root} from 'mdast';
+import {fromMarkdown} from 'mdast-util-from-markdown';
+import {gfm} from 'micromark-extension-gfm';
+import {gfmFromMarkdown} from 'mdast-util-gfm';
 import {replaceTextBetweenStartAndEndWithNewValue} from './strings';
 import {escapeRegExp} from './regex';
-import {remark} from 'remark';
-import remarkGfm from 'remark-gfm';
 
 /* eslint-disable no-unused-vars */
 export enum MDAstTypes {
@@ -24,7 +25,10 @@ export enum MDAstTypes {
 /* eslint-enable no-unused-vars */
 
 function parseTextToAST(text: string): Root {
-  const ast = remark().use(remarkGfm).parse(text);
+  const ast = fromMarkdown(text, {
+    extensions: [gfm()],
+    mdastExtensions: [gfmFromMarkdown()],
+  });
   return ast;
 }
 
@@ -55,7 +59,26 @@ export function getPositions(type: MDAstTypes, text: string): Position[] {
  */
 export function moveFootnotesToEnd(text: string) {
   const positions: Position[] = getPositions(MDAstTypes.Footnote, text);
-  const footnotes: string[] = [];
+  let footnotes: string[] = [];
+
+  const alreadyUsedReferencePositions = new Set<number>();
+  const mapOfFootnoteToFootnoteReferenceIndex = new Map<string, number>();
+  const referencePosition = function(footnote: string, startOfFootnoteReferenceSearch: number): number {
+    const footnoteReference = footnote.match(/\[\^.*?\]/)[0];
+    let footnoteReferenceLocation: number;
+    do {
+      footnoteReferenceLocation = text.lastIndexOf(footnoteReference, startOfFootnoteReferenceSearch);
+      startOfFootnoteReferenceSearch = footnoteReferenceLocation;
+    } while (alreadyUsedReferencePositions.has(footnoteReferenceLocation) && footnoteReferenceLocation !== -1 );
+
+    if (footnoteReferenceLocation === -1) {
+      throw new Error(`Footnote '${footnote}' has no corresponding footnote reference before the footnote contents and cannot be processed. Please make sure that all footnotes have a corresponding reference before the content of the footnote.`);
+    }
+
+    alreadyUsedReferencePositions.add(footnoteReferenceLocation);
+    return footnoteReferenceLocation;
+  };
+
 
   for (const position of positions) {
     const footnote = text.substring(position.start.offset, position.end.offset);
@@ -69,10 +92,13 @@ export function moveFootnotesToEnd(text: string) {
       text = text.substring(0, position.end.offset) + text.substring(position.end.offset + 1);
     }
     text = text.substring(0, position.start.offset) + text.substring(position.end.offset);
+    mapOfFootnoteToFootnoteReferenceIndex.set(footnote, referencePosition(footnote, position.start.offset));
   }
 
-  // Reverse the footnotes so that they are in the same order as the original text
-  footnotes.reverse();
+  // Sort the footnotes into the order of their references in the text
+  footnotes = footnotes.sort((f1: string, f2: string) => {
+    return mapOfFootnoteToFootnoteReferenceIndex.get(f1) - mapOfFootnoteToFootnoteReferenceIndex.get(f2);
+  });
 
   // Add the footnotes to the end of the document
   if (footnotes.length > 0) {


Test patch:
diff --git a/src/test/move-footnotes-to-the-bottom.test.ts b/src/test/move-footnotes-to-the-bottom.test.ts
index 89d8e23..fc72385 100644
--- a/src/test/move-footnotes-to-the-bottom.test.ts
+++ b/src/test/move-footnotes-to-the-bottom.test.ts
@@ -8,11 +8,13 @@ ruleTest({
     {
       testName: 'Simple case',
       before: dedent`
+        This has a footnote reference at the end [^alpha]
         [^alpha]: bravo and charlie.
         ${''}
         Line
       `,
       after: dedent`
+        This has a footnote reference at the end [^alpha]
         Line
         ${''}
         [^alpha]: bravo and charlie.
@@ -119,15 +121,40 @@ ruleTest({
     {
       testName: 'Footnote already at the bottom does not add an extra newline',
       before: dedent`
-        Line
+        Line [^alpha]
         ${''}
         [^alpha]: bravo and charlie.
       `,
       after: dedent`
-        Line
+        Line [^alpha]
         ${''}
         [^alpha]: bravo and charlie.
       `,
     },
+    {
+      testName: 'Make sure that a footnote placed between footnotes that already have content at the end of the file properly gets properly ordered',
+      before: dedent`
+        reference A [^1]
+        ${''}
+        reference C [^2]
+        [^2]: footnote C (inserted between A and B)
+        ${''}
+        reference B [^2]
+        ${''}
+        [^1]: footnote A (first in document)
+        [^2]: footnote B (last in document)
+      `,
+      after: dedent`
+        reference A [^1]
+        ${''}
+        reference C [^2]
+        reference B [^2]
+        ${''}
+        [^1]: footnote A (first in document)
+        [^2]: footnote C (inserted between A and B)
+        [^2]: footnote B (last in document)
+      `,
+    },
   ],
 });
+