Repository: platers/obsidian-linter (ts)

Issue:
FR: Adjust rules for documents containing no H1, but only H2s (Obsidian 0.16)
## Is Your Feature Request Related to a Problem? Please Describe.

With Obsidian 0.16, we get inline titles and tab headers which make H1 redundant, resulting in some people like me to remove all their H1; my notes now only contain H2. 

However, this makes some rules like "Header Increment" unusable, since without an H1, all H2s become H1, and All H3s H2 and so on. [However, as described very well in this forum post, having multiple H1 in one document is also considered bad practice.](https://forum.obsidian.md/t/obsidian-titles-headers/43089/7).

## Describe the Solution You'd Like

Adjust rules like "header-increment" so that they also work for notes that do not contain H1s; maybe as an additional option?

In this regard, a rule like "there should be no H1 in your note" could also be worth considering.

**Please include an example where applicable:**

``` markdown
## heading

## heading
```

``` markdown
expected outcome
```

## Describe Alternatives You've Considered

Simply disabling all the affected rules. However, this would diminish the usefulness of the linter.


Pull request description:
Add Header Increment Support for a Minimum Level of H2
Fixes #412 

Changes Made:
- Made the logic more performant, by only working on lines that are headers by using regex matches instead of going line by line and checking for regex matches
- Updated the logic to set a minimum level of 2 if you so choose for header increment
- Added several tests based on suggested scenarios in #412 
- Removed unused regex

Patch:
diff --git a/src/rules/header-increment.ts b/src/rules/header-increment.ts
index b03b080..0931701 100644
--- a/src/rules/header-increment.ts
+++ b/src/rules/header-increment.ts
@@ -1,10 +1,11 @@
 import {Options, RuleType} from '../rules';
-import RuleBuilder, {ExampleBuilder, OptionBuilderBase} from './rule-builder';
+import RuleBuilder, {BooleanOptionBuilder, ExampleBuilder, OptionBuilderBase} from './rule-builder';
 import dedent from 'ts-dedent';
 import {ignoreListOfTypes, IgnoreTypes} from '../utils/ignore-types';
-import {headerRegex} from '../utils/regex';
+import {allHeadersRegex} from '../utils/regex';
 
 class HeaderIncrementOptions implements Options {
+  startAtH2?: boolean = false;
 }
 
 @RuleBuilder.register
@@ -23,35 +24,35 @@ export default class HeaderIncrement extends RuleBuilder<HeaderIncrementOptions>
   }
   apply(text: string, options: HeaderIncrementOptions): string {
     return ignoreListOfTypes([IgnoreTypes.code, IgnoreTypes.yaml, IgnoreTypes.link, IgnoreTypes.wikiLink, IgnoreTypes.tag], text, (text) => {
-      const lines = text.split('\n');
-      let lastLevel = 0; // level of last header processed
+      let lastLevel = options.startAtH2 ? 1 : 0; // level of last header processed
       let decrement = 0; // number of levels to decrement following headers
-      for (let i = 0; i < lines.length; i++) {
-        const match = lines[i].match(headerRegex);
-        if (!match) {
-          continue;
+      const minimumLevel = options.startAtH2 ? 2: 1;
+
+      return text.replace(allHeadersRegex, (headerText: string, $1: string = '', $2: string = '', $3: string = '', $4: string = '', $5: string = '') => {
+        // skip any headers with a header level less than the minimum
+        if ($2.length < minimumLevel) {
+          lastLevel = minimumLevel - 1; // makes sure that header increment starts off at the minimum level if the last level was greater than it
+          return headerText;
         }
 
-        let level = match[2].length - decrement;
+        let level = $2.length - decrement;
         if (level > lastLevel + 1) {
           decrement += level - (lastLevel + 1);
           level = lastLevel + 1;
         } else if (level < lastLevel) {
-          decrement -= lastLevel + decrement - match[2].length;
+          decrement -= lastLevel + decrement - $2.length;
 
           if (decrement <= 0) {
-            level = match[2].length;
+            level = $2.length;
             decrement = 0;
           }
         }
 
-        lines[i] = lines[i].replace(
-            headerRegex,
-            `$1${'#'.repeat(level)}$3$4`,
-        );
+        level = level < minimumLevel ? minimumLevel : level;
         lastLevel = level;
-      }
-      return lines.join('\n');
+
+        return $1 + '#'.repeat(level) + $3 + $4 + $5;
+      });
     });
   }
   get exampleBuilders(): ExampleBuilder<HeaderIncrementOptions>[] {
@@ -120,9 +121,42 @@ export default class HeaderIncrement extends RuleBuilder<HeaderIncrementOptions>
           ### H6
         `,
       }),
+      new ExampleBuilder({
+        description: 'When start at heading level 2 is set to true, H1s are left alone and the next header will be an H2',
+        before: dedent`
+          # H1 stays the same
+          ### H3 becomes H2
+          ####### H7
+          ###### H6
+          ## H2
+          ###### H6
+          # H1
+          ####### H7
+        `,
+        after: dedent`
+          # H1 stays the same
+          ## H3 becomes H2
+          ### H7
+          ## H6
+          ## H2
+          ### H6
+          # H1
+          ## H7
+        `,
+        options: {
+          startAtH2: true,
+        },
+      }),
     ];
   }
   get optionBuilders(): OptionBuilderBase<HeaderIncrementOptions>[] {
-    return [];
+    return [
+      new BooleanOptionBuilder({
+        OptionsClass: HeaderIncrementOptions,
+        name: 'Start Header Increment at Heading Level 2',
+        description: 'Ignores level 1 headings and makes sure that level 1 headings are followed by an level 2 heading',
+        optionsKey: 'startAtH2',
+      }),
+    ];
   }
 }
diff --git a/src/utils/regex.ts b/src/utils/regex.ts
index fecfa08..3873035 100644
--- a/src/utils/regex.ts
+++ b/src/utils/regex.ts
@@ -2,8 +2,7 @@
 import {makeSureContentHasEmptyLinesAddedBeforeAndAfter} from './strings';
 
 // Useful regexes
-export const headerRegex = /^([ \t]*)(#+)([ \t]+)([^#\n\r]*)([ \t]+#+)?$/m;
-export const allHeadersRegex = new RegExp(headerRegex.source, headerRegex.flags + 'g');
+export const allHeadersRegex = /^([ \t]*)(#+)([ \t]+)([^#\n\r]*)([ \t]+#+)?$/gm;
 export const fencedRegexTemplate = '^XXX\\.*?\n(?:((?:.|\n)*?)\n)?XXX(?=\\s|$)$';
 export const yamlRegex = /^---\n((?:(((?!---)(?:.|\n)*?)\n)?))---(?=\n|$)/;
 export const backtickBlockRegexTemplate = fencedRegexTemplate.replaceAll('X', '`');


Test patch:
diff --git a/__tests__/header-increment.test.ts b/__tests__/header-increment.test.ts
index 0814def..625621c 100644
--- a/__tests__/header-increment.test.ts
+++ b/__tests__/header-increment.test.ts
@@ -66,5 +66,79 @@ ruleTest({
         ### H3
       `,
     },
+    { // accounts for https://github.com/platers/obsidian-linter/issues/412
+      testName: 'When H1 starts file, header increment should act like normal',
+      before: dedent`
+        # H1
+        ### H3
+        #### H4
+        # H1
+        #### H4
+        ###### H6
+
+        H1 at beginning of file: same as existing behavior
+      `,
+      after: dedent`
+        # H1
+        ## H3
+        ### H4
+        # H1
+        ## H4
+        ### H6
+
+        H1 at beginning of file: same as existing behavior
+      `,
+      options: {
+        startAtH2: true,
+      },
+    },
+    { // accounts for https://github.com/platers/obsidian-linter/issues/412
+      testName: 'When H1 does not start the file, H1s are left alone and minimum header is H2 for decremented headers',
+      before: dedent`
+        ### H3
+        #### H4
+        # H1
+        ##### H5
+        ###### H6
+
+        No H1 at beginning of file: No header promoted beyond H2; H1s left alone
+      `,
+      after: dedent`
+        ## H3
+        ### H4
+        # H1
+        ## H5
+        ### H6
+
+        No H1 at beginning of file: No header promoted beyond H2; H1s left alone
+      `,
+      options: {
+        startAtH2: true,
+      },
+    },
+    { // accounts for https://github.com/platers/obsidian-linter/issues/412
+      testName: 'When H1 does not exist in the file, the minimum header is an H2 and incrementing starts from that level',
+      before: dedent`
+        ### H3
+        #### H4
+        ## H2
+        #### H4
+        ###### H6
+
+        Nothing gets promoted above H2
+      `,
+      after: dedent`
+        ## H3
+        ### H4
+        ## H2
+        ### H4
+        #### H6
+
+        Nothing gets promoted above H2
+      `,
+      options: {
+        startAtH2: true,
+      },
+    },
   ],
 });