Repository: platers/obsidian-linter (ts)

Issue:
Bug: Trailing spaces  Removing extra Spaces at the end will affect the outline
- [ ] I have verified that I am on the latest version of the Linter

## Describe the Bug

My blank outline node is removed and becomes "-"
Like this
![image](https://github.com/platers/obsidian-linter/assets/73203026/9db3c89b-119e-479a-a6b0-cf6cf95711c6)




Pull request description:
Fix Trailing Spaces Breaking List Items and Checklist Items
Fixes #868 

There was an issue where empty ordered, checklist, and list items were being broken by trailing spaces. This was because it did not ignore the first space after the indicator. This should be fixed with this latest change.

Changes Made:
- Added UTs for several scenarios
- Made sure to handle list items in particular and handle the case where a list item is empty

Patch:
diff --git a/src/rules/trailing-spaces.ts b/src/rules/trailing-spaces.ts
index 4b11f18..e2049f8 100644
--- a/src/rules/trailing-spaces.ts
+++ b/src/rules/trailing-spaces.ts
@@ -1,7 +1,8 @@
-import {IgnoreTypes} from '../utils/ignore-types';
+import {ignoreListOfTypes, IgnoreTypes} from '../utils/ignore-types';
 import {Options, RuleType} from '../rules';
 import RuleBuilder, {BooleanOptionBuilder, ExampleBuilder, OptionBuilderBase} from './rule-builder';
 import dedent from 'ts-dedent';
+import {updateListItemText} from '../utils/mdast';
 
 class TrailingSpacesOptions implements Options {
   twoSpaceLineBreak: boolean = false;
@@ -22,14 +23,27 @@ export default class TrailingSpaces extends RuleBuilder<TrailingSpacesOptions> {
     return TrailingSpacesOptions;
   }
   apply(text: string, options: TrailingSpacesOptions): string {
-    if (!options.twoSpaceLineBreak) {
-      return text.replace(/[ \t]+$/gm, '');
-    } else {
-      text = text.replace(/(\S)[ \t]$/gm, '$1'); // one whitespace
-      text = text.replace(/(\S)[ \t]{3,}$/gm, '$1'); // three or more whitespaces
-      text = text.replace(/(\S)( ?\t\t? ?)$/gm, '$1'); // two whitespaces with at least one tab
-      return text;
-    }
+    text = ignoreListOfTypes([IgnoreTypes.list], text, (text: string): string => {
+      if (!options.twoSpaceLineBreak) {
+        return text.replace(/[ \t]+$/gm, '');
+      } else {
+        text = text.replace(/(\S)[ \t]$/gm, '$1'); // one whitespace
+        text = text.replace(/(\S)[ \t]{3,}$/gm, '$1'); // three or more whitespaces
+        text = text.replace(/(\S)( ?\t\t? ?)$/gm, '$1'); // two whitespaces with at least one tab
+        return text;
+      }
+    });
+
+    return updateListItemText(text, (text: string): string => {
+      if (!options.twoSpaceLineBreak) {
+        return text.replace(/[ \t]+$/gm, '');
+      } else {
+        text = text.replace(/(\S)[ \t]$/gm, '$1'); // one whitespace
+        text = text.replace(/(\S)[ \t]{3,}$/gm, '$1'); // three or more whitespaces
+        text = text.replace(/(\S)( ?\t\t? ?)$/gm, '$1'); // two whitespaces with at least one tab
+        return text;
+      }
+    }, true);
   }
   get exampleBuilders(): ExampleBuilder<TrailingSpacesOptions>[] {
     return [
diff --git a/src/utils/mdast.ts b/src/utils/mdast.ts
index 4ac9211..1f41fb5 100644
--- a/src/utils/mdast.ts
+++ b/src/utils/mdast.ts
@@ -19,6 +19,11 @@ import {getTextInLanguage} from '../lang/helpers';
 
 const LRU = new QuickLRU({maxSize: 200});
 
+type PositionPlusEmptyIndicator = {
+  position: Position,
+  isEmpty: boolean,
+}
+
 export enum MDAstTypes {
   Link = 'link',
   Footnote = 'footnoteDefinition',
@@ -107,27 +112,40 @@ export function getPositions(type: MDAstTypes, text: string): Position[] {
 /**
  * Gets the positions of the list item text in the given text.
  * @param {string} text - The markdown text
- * @return {Position[]} The positions of the list item text in the given text
+ * @param {boolean} includeEmptyNodes - Whether or not empty list items should be
+ * returned to be handled by the calling function
+ * @return {PositionPlusEmptyIndicator[]} The positions of the list item text in the given text
+ * with a status as to whether or not they are empty
  */
-function getListItemTextPositions(text: string): Position[] {
+function getListItemTextPositions(text: string, includeEmptyNodes: boolean = false): Position[] {
   const ast = parseTextToAST(text);
-  const positions: Position[] = [];
+  const positions: PositionPlusEmptyIndicator[] = [];
   visit(ast, MDAstTypes.ListItem as string, (node) => {
     // @ts-ignore the fact that not all nodes have a children property since I am skipping any that do not
-    if (!node.children) {
+    if (!node.children || node.children.length === 0) {
+      if (includeEmptyNodes) {
+        positions.push({
+          position: node.position,
+          isEmpty: true,
+        });
+      }
+
       return;
     }
 
     // @ts-ignore the fact that not all nodes have a children property since I have already exited the function if that is the case
     for (const childNode of node.children) {
       if (childNode.type === (MDAstTypes.Paragraph as string)) {
-        positions.push(childNode.position);
+        positions.push({
+          position: childNode.position,
+          isEmpty: false,
+        });
       }
     }
   });
 
   // Sort positions by start position in reverse order
-  positions.sort((a, b) => b.start.offset - a.start.offset);
+  positions.sort((a, b) => b.position.start.offset - a.position.start.offset);
   return positions;
 }
 
@@ -587,29 +605,40 @@ export function updateBoldText(text: string, func:(text: string) => string): str
   return text;
 }
 
-export function updateListItemText(text: string, func:(text: string) => string): string {
-  const positions: Position[] = getListItemTextPositions(text);
+export function updateListItemText(text: string, func:(text: string) => string, includeEmptyNodes: boolean = false): string {
+  const positions: PositionPlusEmptyIndicator[] = getListItemTextPositions(text, includeEmptyNodes);
 
   for (const position of positions) {
-    let startIndex = position.start.offset;
-    // get the actual start of the list item leaving only 1 whitespace between the indicator and the text
-    while (startIndex > 0 && text.charAt(startIndex - 1).trim() === '') {
-      startIndex--;
-    }
-    // keep a single space for the indicator
-    if (startIndex === 0 || text.charAt(startIndex - 1).trim() != '') {
+    let startIndex = position.position.start.offset;
+    if (position.isEmpty) {
+      // get the actual start of the list item leaving only 1 whitespace between the indicator and the text
+      while (startIndex < position.position.end.offset && text.charAt(startIndex).trim() !== '') {
+        startIndex++;
+      }
+
       startIndex++;
+    } else {
+      // get the actual start of the list item leaving only 1 whitespace between the indicator and the text
+      while (startIndex > 0 && text.charAt(startIndex - 1).trim() === '') {
+        startIndex--;
+      }
+
+      // keep a single space for the indicator
+      if (startIndex === 0 || text.charAt(startIndex - 1).trim() != '') {
+        startIndex++;
+      }
     }
 
-    let listText = text.substring(startIndex, position.end.offset);
+    let listText = text.substring(startIndex, position.position.end.offset);
     // for some reason some checklists are not getting treated as such and this causes the task indicator to be included in the text
     if (checklistBoxStartsTextRegex.test(listText)) {
       startIndex += 4;
       listText = listText.substring(4);
     }
+
     listText = func(listText);
 
-    text = replaceTextBetweenStartAndEndWithNewValue(text, startIndex, position.end.offset, listText);
+    text = replaceTextBetweenStartAndEndWithNewValue(text, startIndex, position.position.end.offset, listText);
   }
 
   return text;


Test patch:
diff --git a/__tests__/trailing-spaces.test.ts b/__tests__/trailing-spaces.test.ts
index 00cc3a5..f3f535e 100644
--- a/__tests__/trailing-spaces.test.ts
+++ b/__tests__/trailing-spaces.test.ts
@@ -98,5 +98,61 @@ ruleTest({
         [[File with  spaces]]
       `,
     },
+    { // accounts for https://github.com/platers/obsidian-linter/issues/868
+      testName: 'A list item with no content should not have the space indicating it is a list item removed',
+      before: dedent`
+        - List item 1
+        -${' '}
+        -${'   '}
+      `,
+      after: dedent`
+        - List item 1
+        -${' '}
+        -${' '}
+      `,
+    },
+    { // relates to for https://github.com/platers/obsidian-linter/issues/868
+      testName: 'Make sure that checklists are properly handled with trailing spaces',
+      before: dedent`
+        - [ ] List item 1
+        - [ ]${' '}
+        - [ ] ${'   '}
+      `,
+      after: dedent`
+        - [ ] List item 1
+        - [ ]${' '}
+        - [ ]${' '}
+      `,
+    },
+    { // relates to for https://github.com/platers/obsidian-linter/issues/868
+      testName: 'Make sure that indented lists are properly handled with trailing spaces',
+      before: dedent`
+        Text here
+        - List item 1
+          -${' '}
+          -${'   '}
+      `,
+      after: dedent`
+        Text here
+        - List item 1
+          -${' '}
+          -${' '}
+      `,
+    },
+    { // relates to for https://github.com/platers/obsidian-linter/issues/868
+      testName: 'Make sure that ordered lists are properly handled with trailing spaces',
+      before: dedent`
+        Text here
+        1. List item 1
+        2.${' '}
+        3.${'   '}
+      `,
+      after: dedent`
+        Text here
+        1. List item 1
+        2.${' '}
+        3.${' '}
+      `,
+    },
   ],
 });