Repository: platers/obsidian-linter (ts)

Issue:
FR: No bare URIs
## Is Your Feature Request Related to a Problem? Please Describe.
URI such as `obsidian://show-plugin?id=cycle-in-sidebar` and `zotero://select/library/items/6R98DML4` need to be wrapped in `<>` in order to be clickable in Reading Mode.

## Describe the Solution You'd Like

Linter detects these URIs and autowraps them in `<>`, similar to the [No Bare URLs](https://github.com/platers/obsidian-linter/blob/master/docs/rules.md#no-bare-urls) feature.

Example:

Before
``` markdown
obsidian://show-plugin?id=cycle-in-sidebar
```
After
``` markdown
<obsidian://show-plugin?id=cycle-in-sidebar>
```

## Describe Alternatives You've Considered

Just manually add `<` and `>`. However, Obsidian doesn't autopair `<` (understandable because `<` is used a lot for other things), so it's quite cumbersome to do.

Bug: `No Bare URLs` include period and parenthesis at the end of the sentence.
- [x] I have verified that I am on the latest version of the Linter

## Describe the Bug

Please see the example

## How to Reproduce

Input

``` markdown
- https://theintercept.com/2023/05/23/henry-kissinger-cambodia-bombing-survivors/.
- https://www.gettyimages.com/detail/news-photo/617942032.
```
Current output

``` markdown
- <https://theintercept.com/2023/05/23/henry-kissinger-cambodia-bombing-survivors/.>
- <https://www.gettyimages.com/detail/news-photo/617942032.>
```
Expected Behavior

`<>` wrap only the URL and not the period at the end of the sentence as below

``` markdown
- <https://theintercept.com/2023/05/23/henry-kissinger-cambodia-bombing-survivors/>.
- <https://www.gettyimages.com/detail/news-photo/617942032>.
```


## Device

- [x] Desktop Windows 11. Obsidian 1.3.5.

## Additional info

The parenthesis `)` is included in similar behavior.

Pull request description:
Add `No Bare URIs` option and Fix `No Bare URLs` Including Certain Punctuation
Fixes #776 
Fixes #767 

Changes Made:
- Added UTs for the scenarios where certain punctuation was being allowed as part of the end of the link
- Added a new option to `No Bare URLs` called `No Bare URIs` that will do its best to escape URIs in the format `SCHEME_HERE://REST_OF_VALUE_HERE`
- Added an English and Spanish translation as they are all I know and I leave the remaining translations to contributions down the road
- Refactored `No Bare URLs` logic to make it something that can be run for both regex matches
- Made sure to ignore http, https, ftp, and smtp schemes for uri matches
- Updated URL regex and added a simple URI regex

Patch:
diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts
index de678fd..d3b7980 100644
--- a/src/lang/locale/en.ts
+++ b/src/lang/locale/en.ts
@@ -457,6 +457,10 @@ export default {
     'no-bare-urls': {
       'name': 'No Bare URLs',
       'description': 'Encloses bare URLs with angle brackets except when enclosed in back ticks, square braces, or single or double quotes.',
+      'no-bare-uris': {
+        'name': 'No Bare URIs',
+        'description': 'Attempts to enclose bare URIs with angle brackets except when enclosed in back ticks, square braces, or single or double quotes.',
+      },
     },
     // ordered-list-style.ts
     'ordered-list-style': {
diff --git a/src/lang/locale/es.ts b/src/lang/locale/es.ts
index 01392d6..9cafa6f 100644
--- a/src/lang/locale/es.ts
+++ b/src/lang/locale/es.ts
@@ -391,6 +391,10 @@ export default {
     'no-bare-urls': {
       'name': 'Sin URL desnuda',
       'description': 'Encierra las direcciones URL desnudas con corchetes angulares, excepto cuando están encerradas en marcas traseras, llaves cuadradas o comillas simples o dobles.',
+      'no-bare-uris': {
+        'name': 'Sin URI desnuda',
+        'description': 'Encierra las direcciones URI desnudas con corchetes angulares, excepto cuando están encerradas en marcas traseras, llaves cuadradas o comillas simples o dobles.',
+      },
     },
     'ordered-list-style': {
       'name': 'Estilo de lista ordenada',
diff --git a/src/rules/no-bare-urls.ts b/src/rules/no-bare-urls.ts
index 9d1c64f..25e7676 100644
--- a/src/rules/no-bare-urls.ts
+++ b/src/rules/no-bare-urls.ts
@@ -1,13 +1,16 @@
 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 {IgnoreTypes} from '../utils/ignore-types';
 import {replaceTextBetweenStartAndEndWithNewValue} from '../utils/strings';
-import {urlRegex} from '../utils/regex';
+import {simpleURIRegex, urlRegex} from '../utils/regex';
 
-class NoBareUrlsOptions implements Options {}
+class NoBareUrlsOptions implements Options {
+  noBareURIs?: boolean = false;
+}
 
 const specialCharsToNotEscapeContentsWithin = `'"‘’“”\`[]`;
+const uriSchemesToIgnore = ['http', 'ftp', 'https', 'smtp'];
 
 @RuleBuilder.register
 export default class NoBareUrls extends RuleBuilder<NoBareUrlsOptions> {
@@ -24,23 +27,41 @@ export default class NoBareUrls extends RuleBuilder<NoBareUrlsOptions> {
   }
   apply(text: string, options: NoBareUrlsOptions): string {
     const URLMatches = text.match(urlRegex);
+    if (URLMatches) {
+      text = this.handleMatches(text, URLMatches, false);
+    }
+
 
-    if (!URLMatches) {
-      return text;
+    if (options.noBareURIs) {
+      const URIMatches = text.match(simpleURIRegex);
+      if (URIMatches) {
+        text = this.handleMatches(text, URIMatches, true);
+      }
     }
 
+    return text;
+  }
+  handleMatches(text: string, matches: RegExpMatchArray, isURISearch: boolean): string {
     // make sure you do not match on the same thing more than once by keeping track of the last position you checked up to
     let startSearch = 0;
-    const numMatches = URLMatches.length;
+    const numMatches = matches.length;
     for (let i = 0; i < numMatches; i++) {
-      const urlMatch = URLMatches[i];
-      const urlStart = text.indexOf(urlMatch, startSearch);
-      const urlEnd = urlStart + urlMatch.length;
+      let urlMatch = matches[i];
+      let urlStart = text.indexOf(urlMatch, startSearch);
+      let urlEnd = urlStart + urlMatch.length;
+      if (urlMatch.charAt(0) === '<') {
+        urlMatch = urlMatch.substring(1);
+        urlStart++;
+      }
+
+      if (urlMatch.charAt(urlMatch.length - 1) === '>') {
+        urlMatch = urlMatch.substring(0, urlMatch.length - 1);
+        urlEnd--;
+      }
 
       const previousChar = urlStart === 0 ? undefined : text.charAt(urlStart - 1);
       const nextChar = urlEnd >= text.length ? undefined : text.charAt(urlEnd);
-      if (previousChar != undefined && specialCharsToNotEscapeContentsWithin.includes(previousChar) &&
-          nextChar != undefined && specialCharsToNotEscapeContentsWithin.includes(nextChar)) {
+      if (this.skipMatch(previousChar, nextChar, urlMatch, isURISearch)) {
         startSearch = urlStart + urlMatch.length;
         continue;
       }
@@ -65,9 +86,21 @@ export default class NoBareUrls extends RuleBuilder<NoBareUrlsOptions> {
       text = replaceTextBetweenStartAndEndWithNewValue(text, urlStart, urlStart + urlMatch.length, '<' + urlMatch + '>');
       startSearch = urlStart + urlMatch.length + 2;
     }
-
     return text;
   }
+  skipMatch(previousChar: string, nextChar: string, match: string, isURISearch: boolean) {
+    const startsWithSpecialCharacter = (previousChar != undefined && specialCharsToNotEscapeContentsWithin.includes(previousChar)) || specialCharsToNotEscapeContentsWithin.includes(match.charAt(0));
+    const endsWithSpecialCharacter = (nextChar != undefined && specialCharsToNotEscapeContentsWithin.includes(nextChar)) || specialCharsToNotEscapeContentsWithin.includes(match.charAt(match.length - 1));
+    if (startsWithSpecialCharacter && endsWithSpecialCharacter) {
+      return true;
+    }
+
+    if (isURISearch) {
+      return uriSchemesToIgnore.includes(match.substring(0, match.indexOf(':')));
+    }
+
+    return false;
+  }
   get exampleBuilders(): ExampleBuilder<NoBareUrlsOptions>[] {
     return [
       new ExampleBuilder({
@@ -123,9 +156,28 @@ export default class NoBareUrls extends RuleBuilder<NoBareUrlsOptions> {
           <https://gitlab.com>
         `,
       }),
+      new ExampleBuilder({// accounts for https://github.com/platers/obsidian-linter/issues/776
+        description: 'Puts angle brackets around URIs when `No Bare URIs` is enabled',
+        before: dedent`
+          obsidian://show-plugin?id=cycle-in-sidebar
+        `,
+        after: dedent`
+          <obsidian://show-plugin?id=cycle-in-sidebar>
+        `,
+        options: {
+          noBareURIs: true,
+        },
+      }),
     ];
   }
   get optionBuilders(): OptionBuilderBase<NoBareUrlsOptions>[] {
-    return [];
+    return [
+      new BooleanOptionBuilder({
+        OptionsClass: NoBareUrlsOptions,
+        nameKey: 'rules.no-bare-urls.no-bare-uris.name',
+        descriptionKey: 'rules.no-bare-urls.no-bare-uris.description',
+        optionsKey: 'noBareURIs',
+      }),
+    ];
   }
 }
diff --git a/src/utils/regex.ts b/src/utils/regex.ts
index 9024df4..00f4e91 100644
--- a/src/utils/regex.ts
+++ b/src/utils/regex.ts
@@ -1,4 +1,3 @@
-
 import {getAllTablesInText} from './mdast';
 import {makeSureContentHasEmptyLinesAddedBeforeAndAfter} from './strings';
 
@@ -22,7 +21,10 @@ export const lineStartingWithWhitespaceOrBlockquoteTemplate = `\\s*(>\\s*)*`;
 export const tableSeparator = /(\|? *:?-{1,}:? *\|?)(\| *:?-{1,}:? *\|?)*( |\t)*$/gm;
 export const tableStartingPipe = /^(((>[ ]?)*)|([ ]{0,3}))\|/m;
 export const tableRow = /[^\n]*?\|[^\n]*?(\n|$)/m;
-export const urlRegex = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s`\]'"‘’“”>]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s`\]'"‘’“”>]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s`\]'"‘’“”>]{2,}|www\.[a-zA-Z0-9]+\.[^\s`\]'"‘’“”>]{2,})/gi;
+// based on https://gist.github.com/skeller88/5eb73dc0090d4ff1249a
+export const simpleURIRegex = /(([a-z\-0-9]+:)\/{2})([^\s/?#]*[^\s")'.?!/]|[/])?(([/?#][^\s")']*[^\s")'.?!])|[/])?/gi;
+// generated from https://github.com/spamscanner/url-regex-safe using strict: true, returnString: true, and re2: false as options
+export const urlRegex = /(?:(?:(?:[a-z]+:)?\/\/)|www\.)(?:localhost|(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?:(?:[a-fA-F\d]{1,4}:){7}(?:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,2}|:)|(?:[a-fA-F\d]{1,4}:){4}(?:(?::[a-fA-F\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,3}|:)|(?:[a-fA-F\d]{1,4}:){3}(?:(?::[a-fA-F\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,4}|:)|(?:[a-fA-F\d]{1,4}:){2}(?:(?::[a-fA-F\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,5}|:)|(?:[a-fA-F\d]{1,4}:){1}(?:(?::[a-fA-F\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?|(?:(?:[a-z\u00a1-\uffff0-9][-_]*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:(?:[/?#][^\s")']*[^\s")'.?!])|[/])?/gi;
 export const anchorTagRegex = /<a[\s]+([^>]+)>((?:.(?!<\/a>))*.)<\/a>/g;
 export const wordRegex = /[\p{L}\p{N}\p{Pc}\p{M}\-'’`]+/gu;
 // regex from https://stackoverflow.com/a/26128757/8353749


Test patch:
diff --git a/__tests__/no-bare-urls.test.ts b/__tests__/no-bare-urls.test.ts
index 6405eba..9ad7f98 100644
--- a/__tests__/no-bare-urls.test.ts
+++ b/__tests__/no-bare-urls.test.ts
@@ -5,8 +5,7 @@ import {ruleTest} from './common';
 ruleTest({
   RuleBuilderClass: NoBareUrls,
   testCases: [
-    {
-      // accounts for https://github.com/platers/obsidian-linter/issues/275
+    {// accounts for https://github.com/platers/obsidian-linter/issues/275
       testName: 'Leaves markdown links and images alone',
       before: dedent`
         [regular link](https://google.com)
@@ -17,8 +16,7 @@ ruleTest({
         ![image alt text](https://github.com/favicon.ico)
       `,
     },
-    {
-      // accounts for https://github.com/platers/obsidian-linter/issues/339
+    {// accounts for https://github.com/platers/obsidian-linter/issues/339
       testName: 'Urls with a hashtag referring to header that are surrounded by `<` and `> should be left alone',
       before: dedent`
         <https://google.com#hashtag>
@@ -67,5 +65,38 @@ ruleTest({
         > <iframe width="100%" height="600" src=" https://www.google.com "></iframe>
       `,
     },
+    {// accounts for https://github.com/platers/obsidian-linter/issues/776
+      testName: 'Trailing periods should not be included in the URL that gets enclosed in angle brackets',
+      before: dedent`
+        - https://theintercept.com/2023/05/23/henry-kissinger-cambodia-bombing-survivors/.
+        - https://www.gettyimages.com/detail/news-photo/617942032.
+      `,
+      after: dedent`
+        - <https://theintercept.com/2023/05/23/henry-kissinger-cambodia-bombing-survivors/>.
+        - <https://www.gettyimages.com/detail/news-photo/617942032>.
+      `,
+    },
+    {// accounts for https://github.com/platers/obsidian-linter/issues/776
+      testName: 'Trailing parentheses should not be included in the URL that gets enclosed in angle brackets',
+      before: dedent`
+        This is a url followed by a paren https://github.com). Wow that worked!
+      `,
+      after: dedent`
+        This is a url followed by a paren <https://github.com>). Wow that worked!
+      `,
+    },
+    {// accounts for https://github.com/platers/obsidian-linter/issues/776
+      testName: 'Does not put angle brackets around URIs when `No Bare URIs` is not enabled',
+      before: dedent`
+        obsidian://show-plugin?id=cycle-in-sidebar
+      `,
+      after: dedent`
+        obsidian://show-plugin?id=cycle-in-sidebar
+      `,
+      options: {
+        noBareURIs: false,
+      },
+    },
   ],
 });
+