Repository: platers/obsidian-linter (ts)

Issue:
Bug: Quote style is not applied 
- [X] I have verified that I am on the latest version of the Linter

## Describe the Bug

When the file has an even number of straight single quotes, Linter complains that it doesn't, and won't make the straight quotes curly.

## How to Reproduce

Write a file with one or more words in single quotes, and save it, triggering Linter. 

## Expected Behavior

Curly quotes and no complaints :-)

By the way, the "even number" test seems unwise, given that in many languages, like Dutch and English, single instances of single quotes are used, as in ``zo'n`` and ``doesn't``.

## Screenshots
<img width="265" alt="image" src="https://github.com/platers/obsidian-linter/assets/34862159/fb00d2d5-a67b-4e23-bb39-5942627e4f92">

<img width="336" alt="image" src="https://github.com/platers/obsidian-linter/assets/34862159/77660be8-3d77-4d9d-9ad4-2c1e9acb9212">

## Device

- [X] Desktop
- [ ] Mobile






Pull request description:
Update `Quote Style` Smart Quote Logic to Make it More Robust
Fixes #885 

Changes Made:
- Added several new UTs
- Removed list of contractions
- Swapped to logic where we consider a contraction a single quote between two unicode letters
- Swapped logic to make it so that it will swap to an opening smart quote when followed by non-whitespace and proceeded by whitespace
- Swapped logic to make it so that it will swap to a closing smart quote when proceeded by non-whitespace and followed by whitespace
- If that fails, it will rely on the last quote it inserted that is not a contraction to determine whether to do an opening or closing quote.

Patch:
diff --git a/src/lang/locale/de.ts b/src/lang/locale/de.ts
index 364e024..d99e784 100644
--- a/src/lang/locale/de.ts
+++ b/src/lang/locale/de.ts
@@ -83,9 +83,6 @@ export default {
     // rules.ts
     'wrapper-yaml-error': 'Fehler in der YAML: {ERROR_MESSAGE}',
     'wrapper-unknown-error': 'Unbekannter Fehler: {ERROR_MESSAGE}',
-
-    // quote-style.ts
-    'uneven-amount-of-quotes': 'Die Vorkommen des Anführungszeichens `{QUOTE}` in der Datei sind nicht gerade, daher können wir gerade Anführungszeichen nicht in intelligente Anführungszeichen umwandeln',
   },
 
   'notice-text': {
diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts
index d3b7980..e42711e 100644
--- a/src/lang/locale/en.ts
+++ b/src/lang/locale/en.ts
@@ -83,9 +83,6 @@ export default {
     // rules.ts
     'wrapper-yaml-error': 'error in the YAML: {ERROR_MESSAGE}',
     'wrapper-unknown-error': 'unknown error: {ERROR_MESSAGE}',
-
-    // quote-style.ts
-    'uneven-amount-of-quotes': 'The instances of the `{QUOTE}` quote in the file is not even, so we cannot convert straight quotes to smart quotes',
   },
 
   'notice-text': {
diff --git a/src/lang/locale/es.ts b/src/lang/locale/es.ts
index 9cafa6f..478e4f5 100644
--- a/src/lang/locale/es.ts
+++ b/src/lang/locale/es.ts
@@ -67,7 +67,6 @@ export default {
     'too-many-footnotes-error-message': `La clave de nota al pie '{FOOTNOTE_KEY}' tiene más de 1 nota al pie que hace referencia a ella. Actualice las notas al pie para que solo haya una nota al pie por clave de nota al pie.`,
     'wrapper-yaml-error': 'hubo un error en el YAML: {ERROR_MESSAGE}',
     'wrapper-unknown-error': 'huno un error desconocido: {ERROR_MESSAGE}',
-    'uneven-amount-of-quotes': 'Las instancias de la cita `{QUOTE}` en el archivo no son pares, por lo que no podemos convertir las comillas rectas en comillas tipográficas.',
   },
   'notice-text': {
     'empty-clipboard': 'No hay contenido del portapapeles.',
diff --git a/src/lang/locale/tr.ts b/src/lang/locale/tr.ts
index 347088c..86efbb6 100644
--- a/src/lang/locale/tr.ts
+++ b/src/lang/locale/tr.ts
@@ -83,9 +83,6 @@ export default {
     // rules.ts
     'wrapper-yaml-error': 'YAML\'da hata: {ERROR_MESSAGE}',
     'wrapper-unknown-error': 'bilinmeyen hata: {ERROR_MESSAGE}',
-
-    // quote-style.ts
-    'uneven-amount-of-quotes': 'Dosyadaki `{QUOTE}` alıntısının sayısı çift değil, bu yüzden düz alıntıları akıllı alıntılara dönüştüremeyiz',
   },
 
   'notice-text': {
diff --git a/src/lang/locale/zh-cn.ts b/src/lang/locale/zh-cn.ts
index 2e82959..c96b774 100644
--- a/src/lang/locale/zh-cn.ts
+++ b/src/lang/locale/zh-cn.ts
@@ -83,9 +83,6 @@ export default {
     // rules.ts
     'wrapper-yaml-error': 'yaml出现错误: {ERROR_MESSAGE}',
     'wrapper-unknown-error': '未知错误: {ERROR_MESSAGE}',
-
-    // quote-style.ts
-    'uneven-amount-of-quotes': '`{QUOTE}` 引号在文件中的数量不是偶数，所以我们无法将直引号转换为弯引号',
   },
 
   'notice-text': {
diff --git a/src/rules/quote-style.ts b/src/rules/quote-style.ts
index c8e214b..957a3c7 100644
--- a/src/rules/quote-style.ts
+++ b/src/rules/quote-style.ts
@@ -2,9 +2,8 @@ import {IgnoreTypes} from '../utils/ignore-types';
 import {Options, RuleType} from '../rules';
 import RuleBuilder, {BooleanOptionBuilder, DropdownOptionBuilder, ExampleBuilder, OptionBuilderBase} from './rule-builder';
 import dedent from 'ts-dedent';
-import {smartDoubleQuoteRegex, smartSingleQuoteRegex} from '../utils/regex';
-import {countInstances} from '../utils/strings';
-import {getTextInLanguage} from '../lang/helpers';
+import {smartDoubleQuoteRegex, smartSingleQuoteRegex, unicodeLetterRegex} from '../utils/regex';
+import {replaceTextBetweenStartAndEndWithNewValue, getSubstringIndex} from '../utils/strings';
 
 export enum SingleQuoteStyles {
   Straight = '\'\'',
@@ -16,187 +15,6 @@ export enum DoubleQuoteStyles {
   SmartQuote = '“”',
 }
 
-// from https://en.wikipedia.org/wiki/Wikipedia:List_of_English_contractions with any contractions
-// missing a single quote removed
-const englishContractions = [
-  'a\'ight',
-  'ain\'t',
-  'amn\'t',
-  '\'n\'',
-  'aren\'t',
-  '\'bout',
-  'boy\'s',
-  'can\'t',
-  'cap\'n',
-  '\'cause',
-  '\'cept',
-  'could\'ve',
-  'couldn\'t',
-  'couldn\'t\'ve',
-  'daren\'t',
-  'daresn\'t',
-  'dasn\'t',
-  'didn\'t',
-  'doesn\'t',
-  'don\'t',
-  'd\'ye',
-  'd\'ya',
-  'e\'en',
-  'e\'er',
-  '\'em',
-  'everybody\'s',
-  'everyone\'s',
-  'everything\'s',
-  'fo\'c\'sle',
-  '\'gainst',
-  'g\'day',
-  'girl\'s',
-  'giv\'n',
-  'gi\'z',
-  'gon\'t',
-  'guy\'s',
-  'hadn\'t',
-  'had\'ve',
-  'hasn\'t',
-  'haven\'t',
-  'he\'d',
-  'he\'ll',
-  'he\'s',
-  'here\'s',
-  'how\'d',
-  'how\'ll',
-  'how\'re',
-  'how\'s',
-  'I\'d',
-  'I\'d\'ve',
-  'I\'d\'nt',
-  'I\'d\'nt\'ve',
-  'If\'n',
-  'I\'ll',
-  'I\'m',
-  'I\'m\'o',
-  'I\'ve',
-  'isn\'t',
-  'it\'d',
-  'it\'ll',
-  'it\'s',
-  'let\'s',
-  'loven\'t',
-  'ma\'am',
-  'mayn\'t',
-  'may\'ve',
-  'mightn\'t',
-  'might\'ve',
-  'mine\'s',
-  'mustn\'t',
-  'mustn\'t\'ve',
-  'must\'ve',
-  '\'neath',
-  'needn\'t',
-  'ne\'er',
-  'o\'clock',
-  'o\'er',
-  'ol\'',
-  'ought\'ve',
-  'oughtn\'t',
-  'oughtn\'t\'ve',
-  '\'round',
-  'shalln\'t',
-  'shan\'',
-  'shan\'t',
-  'she\'d',
-  'she\'ll',
-  'she\'s',
-  'should\'ve',
-  'shouldn\'t',
-  'shouldn\'t\'ve',
-  'somebody\'s',
-  'someone\'s',
-  'something\'s',
-  'so\'re',
-  'so\'s',
-  'so\'ve',
-  'that\'ll',
-  'that\'re',
-  'that\'s',
-  'that\'d',
-  'there\'d',
-  'there\'ll',
-  'there\'re',
-  'there\'s',
-  'these\'re',
-  'these\'ve',
-  'they\'d',
-  'they\'d\'ve',
-  'they\'ll',
-  'they\'re',
-  'they\'ve',
-  'this\'s',
-  'those\'re',
-  'those\'ve',
-  '\'thout',
-  '\'til',
-  '\'tis',
-  'to\'ve',
-  '\'twas',
-  '\'tween',
-  '\'twere',
-  'w\'all',
-  'w\'at',
-  'wasn\'t',
-  'we\'d',
-  'we\'d\'ve',
-  'we\'ll',
-  'we\'re',
-  'we\'ve',
-  'weren\'t',
-  'what\'d',
-  'what\'ll',
-  'what\'re',
-  'what\'s',
-  'what\'ve',
-  'when\'s',
-  'where\'d',
-  'where\'ll',
-  'where\'re',
-  'where\'s',
-  'where\'ve',
-  'which\'d',
-  'which\'ll',
-  'which\'re',
-  'which\'s',
-  'which\'ve',
-  'who\'d',
-  'who\'d\'ve',
-  'who\'ll',
-  'who\'re',
-  'who\'s',
-  'who\'ve',
-  'why\'d',
-  'why\'re',
-  'why\'s',
-  'willn\'t',
-  'won\'t',
-  'would\'ve',
-  'wouldn\'t',
-  'wouldn\'t\'ve',
-  'y\'ain\'t',
-  'y\'all',
-  'y\'all\'d\'ve',
-  'y\'all\'d\'n\'t\'ve',
-  'y\'all\'re',
-  'y\'all\'ren\'t',
-  'y\'at',
-  'yes\'m',
-  'y\'know',
-  'you\'d',
-  'you\'ll',
-  'you\'re',
-  'you\'ve',
-  'when\'d',
-  'willn\'t',
-];
-
 class QuoteStyleOptions implements Options {
   singleQuoteStyleEnabled?: boolean = true;
   singleQuoteStyle?: SingleQuoteStyles = SingleQuoteStyles.Straight;
@@ -223,7 +41,7 @@ export default class QuoteStyle extends RuleBuilder<QuoteStyleOptions> {
       if (options.doubleQuoteStyle === DoubleQuoteStyles.Straight) {
         newText = this.convertSmartDoubleQuotesToStraightQuotes(newText);
       } else {
-        newText = this.convertStraightDoubleQuotesToDoubleSmartQuotes(newText);
+        newText = this.convertStraightQuoteToSmartQuote(newText, '"', DoubleQuoteStyles.SmartQuote[0], DoubleQuoteStyles.SmartQuote[1], false);
       }
     }
 
@@ -231,7 +49,7 @@ export default class QuoteStyle extends RuleBuilder<QuoteStyleOptions> {
       if (options.singleQuoteStyle === SingleQuoteStyles.Straight) {
         newText = this.convertSmartSingleQuotesToStraightQuotes(newText);
       } else {
-        newText = this.convertStraightSingleQuotesToSingleSmartQuotes(newText);
+        newText = this.convertStraightQuoteToSmartQuote(newText, '\'', SingleQuoteStyles.SmartQuote[0], SingleQuoteStyles.SmartQuote[1], true);
       }
     }
 
@@ -243,78 +61,53 @@ export default class QuoteStyle extends RuleBuilder<QuoteStyleOptions> {
   convertSmartDoubleQuotesToStraightQuotes(text: string): string {
     return text.replace(smartDoubleQuoteRegex, '"');
   }
-  // based on https://inkplant.com/tools/smart-quotes-converter
-  convertStraightDoubleQuotesToDoubleSmartQuotes(text: string): string {
-    this.throwErrorIfNotEqualNumberOfQuotes(text, '"');
-    const openingDoubleQuote = DoubleQuoteStyles.SmartQuote[0];
-    const endingDoubleQuote = DoubleQuoteStyles.SmartQuote[1];
-
-    let numReplacementsMade = 0;
-    text = text.replaceAll('"', () => {
-      let doubleQuote = openingDoubleQuote;
-      if (numReplacementsMade % 2 === 1) {
-        doubleQuote = endingDoubleQuote;
-      }
-
-      numReplacementsMade++;
-
-      return doubleQuote;
-    });
-
-    return text;
-  }
-  // based on https://inkplant.com/tools/smart-quotes-converter
-  convertStraightSingleQuotesToSingleSmartQuotes(text: string): string {
-    const openingSingleQuote = SingleQuoteStyles.SmartQuote[0];
-    const endingSingleQuote = SingleQuoteStyles.SmartQuote[1];
-
-    text = this.convertContractionStraightQuotesToSmartQuotes(text, openingSingleQuote, endingSingleQuote);
-
-    text = this.convertPossessiveStraightQuotesToSmartQuotes(text, endingSingleQuote);
-
-    this.throwErrorIfNotEqualNumberOfQuotes(text, '\'');
-
-    let numReplacementsMade = 0;
-    text = text.replaceAll('\'', () => {
-      let singleQuote = openingSingleQuote;
-      if (numReplacementsMade % 2 === 1) {
-        singleQuote = endingSingleQuote;
-      }
-
-      numReplacementsMade++;
-
-      return singleQuote;
-    });
+  convertStraightQuoteToSmartQuote(text: string, straightQuote: string, openingSmartQuote: string, closingSmartQuote: string, isForSingleQuotes: boolean): string {
+    const indices = getSubstringIndex(straightQuote, text);
+    if (indices.length === 0) {
+      return text;
+    }
 
-    return text;
-  }
-  convertContractionStraightQuotesToSmartQuotes(text: string, openingSmartQuote: string, closingSmartQuote: string): string {
-    const replaceMatchQuotes = function(match: string) {
-      if (match[0] === '\'') {
-        match = openingSmartQuote + match.substring(1);
+    const endOfText = text.length - 1;
+    let quoteReplacement: string;
+    let previousChar = '';
+    let nextChar = '';
+    let previousCharIsALetter = false;
+    let nextCharIsALetter = false;
+    let previousCharIsWhitespace = false;
+    let nextCharIsWhitespace = false;
+    let isContraction = false;
+    let previousQuote = '';
+    for (const index of indices) {
+      previousChar = index == 0 ? '' : text.charAt(index - 1);
+      nextChar = index === endOfText ? '' : text.charAt(index + 1);
+      previousCharIsALetter = unicodeLetterRegex.test(previousChar);
+      nextCharIsALetter = unicodeLetterRegex.test(nextChar);
+      isContraction = previousCharIsALetter && nextCharIsALetter;
+      previousCharIsWhitespace = previousChar != '' && previousChar.trim() === '';
+      nextCharIsWhitespace = nextChar != '' && nextChar.trim() === '';
+      if (isContraction && isForSingleQuotes) {
+        quoteReplacement = closingSmartQuote;
+      } else if (nextCharIsWhitespace && !previousCharIsWhitespace) {
+        quoteReplacement = closingSmartQuote;
+        previousQuote = quoteReplacement;
+      } else if (previousCharIsWhitespace && !nextCharIsWhitespace) {
+        quoteReplacement = openingSmartQuote;
+        previousQuote = quoteReplacement;
+      } else { // this case is meant for languages that do not have a concept of letters like Japanese or Chinese or in the case that we have a scenario where no non-whitespace surrounding the quote
+        if (previousQuote === '' || previousQuote === closingSmartQuote) {
+          quoteReplacement = openingSmartQuote;
+        } else {
+          quoteReplacement = closingSmartQuote;
+        }
+
+        previousQuote = quoteReplacement;
       }
 
-      return match.replaceAll('\'', closingSmartQuote);
-    };
-
-    for (const contraction of englishContractions) {
-      text = text.replace(new RegExp(contraction, 'gi'), replaceMatchQuotes);
+      text = replaceTextBetweenStartAndEndWithNewValue(text, index, index + 1, quoteReplacement);
     }
 
     return text;
   }
-  convertPossessiveStraightQuotesToSmartQuotes(text: string, smartQuote: string): string {
-    return text.replace(/([a-zA-Z0-9]'s|s')/g, (match: string) => {
-      return match.replace('\'', smartQuote);
-    });
-  }
-  throwErrorIfNotEqualNumberOfQuotes(text: string, quote: string) {
-    const numInstances = countInstances(text, quote);
-
-    if (numInstances % 2 !== 0) {
-      throw new Error(getTextInLanguage('logs.uneven-amount-of-quotes').replace('{QUOTE}', quote));
-    }
-  }
   get exampleBuilders(): ExampleBuilder<QuoteStyleOptions>[] {
     return [
       new ExampleBuilder<QuoteStyleOptions>({
diff --git a/src/utils/regex.ts b/src/utils/regex.ts
index 00f4e91..e96b45c 100644
--- a/src/utils/regex.ts
+++ b/src/utils/regex.ts
@@ -46,6 +46,8 @@ export const nonBlockquoteChecklistRegex = new RegExp(`^\\s*- ${checklistBoxIndi
 export const footnoteDefinitionIndicatorAtStartOfLine = /^(\[\^\w+\]) ?([,.;!:?])/gm;
 export const calloutRegex = /^(>\s*)+\[![^\s]*\]/m;
 
+export const unicodeLetterRegex = RegExp(/\p{L}/, 'u');
+
 // https://stackoverflow.com/questions/38866071/javascript-replace-method-dollar-signs
 // Important to use this for any regex replacements where the replacement string
 // could have user constructed dollar signs in it
diff --git a/src/utils/strings.ts b/src/utils/strings.ts
index a398aa4..b09f36b 100644
--- a/src/utils/strings.ts
+++ b/src/utils/strings.ts
@@ -366,3 +366,13 @@ export function isNumeric(str: string) {
   return !isNaN(str as unknown as number) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)...
          !isNaN(parseFloat(str)); // ...and ensure strings of whitespace fail
 }
+
+export function getSubstringIndex(substring: string, text: string): number[] {
+  const indexes = [];
+  let i = -1;
+  while ((i = text.indexOf(substring, i + 1)) >= 0) {
+    indexes.push(i);
+  }
+
+  return indexes;
+}


Test patch:
diff --git a/__tests__/quote-style.test.ts b/__tests__/quote-style.test.ts
index 90d7616..61e6b2c 100644
--- a/__tests__/quote-style.test.ts
+++ b/__tests__/quote-style.test.ts
@@ -20,5 +20,96 @@ ruleTest({
         doubleQuoteStyle: DoubleQuoteStyles.SmartQuote,
       },
     },
+    { // accounts for https://github.com/platers/obsidian-linter/issues/885
+      testName: 'Make sure that more nuanced scenarios are handled properly when using smart quotes for foreign languages',
+      before: dedent`
+        Het 'is' zo.
+      `,
+      after: dedent`
+        Het ‘is’ zo.
+      `,
+      options: {
+        singleQuoteStyle: SingleQuoteStyles.SmartQuote,
+        doubleQuoteStyle: DoubleQuoteStyles.SmartQuote,
+      },
+    },
+    { // relates to https://github.com/platers/obsidian-linter/issues/885
+      testName: 'Make sure if a double quote starts a file, it becomes an opening smart quote when using smart double quotes',
+      before: dedent`
+        "
+      `,
+      after: dedent`
+        “
+      `,
+      options: {
+        singleQuoteStyle: SingleQuoteStyles.SmartQuote,
+        doubleQuoteStyle: DoubleQuoteStyles.SmartQuote,
+      },
+    },
+    { // relates to https://github.com/platers/obsidian-linter/issues/885
+      testName: 'Make sure if a double quote starts a file, it becomes an opening smart quote when using smart single quotes',
+      before: dedent`
+        '
+      `,
+      after: dedent`
+        ‘
+      `,
+      options: {
+        singleQuoteStyle: SingleQuoteStyles.SmartQuote,
+        doubleQuoteStyle: DoubleQuoteStyles.SmartQuote,
+      },
+    },
+    { // relates to https://github.com/platers/obsidian-linter/issues/885
+      testName: 'Make sure if a double quote starts a file, it becomes an opening smart quote when using smart single quotes',
+      before: dedent`
+        '
+      `,
+      after: dedent`
+        ‘
+      `,
+      options: {
+        singleQuoteStyle: SingleQuoteStyles.SmartQuote,
+        doubleQuoteStyle: DoubleQuoteStyles.SmartQuote,
+      },
+    },
+    { // relates to https://github.com/platers/obsidian-linter/issues/885
+      testName: 'Make sure quotes in a sentence get handled correctly with smart quotes',
+      before: dedent`
+        He said to his friend, "How are you able to say 'this is fun' when we could get grounded?" But he got no response.
+      `,
+      after: dedent`
+        He said to his friend, “How are you able to say ‘this is fun’ when we could get grounded?” But he got no response.
+      `,
+      options: {
+        singleQuoteStyle: SingleQuoteStyles.SmartQuote,
+        doubleQuoteStyle: DoubleQuoteStyles.SmartQuote,
+      },
+    },
+    { // relates to https://github.com/platers/obsidian-linter/issues/885
+      testName: 'Make sure that a letter before and after a single quote get interpreted as a contraction',
+      before: dedent`
+        zo'n
+      `,
+      after: dedent`
+        zo’n
+      `,
+      options: {
+        singleQuoteStyle: SingleQuoteStyles.SmartQuote,
+        doubleQuoteStyle: DoubleQuoteStyles.SmartQuote,
+      },
+    },
+    { // relates to https://github.com/platers/obsidian-linter/issues/885
+      testName: 'Make sure that replacing quotes with smart quotes works properly in partial replace situations',
+      before: dedent`
+        He said to his friend, “How are you able to say ‘this is fun.' when we could get grounded?" But he got no response.
+      `,
+      after: dedent`
+        He said to his friend, “How are you able to say ‘this is fun.’ when we could get grounded?” But he got no response.
+      `,
+      options: {
+        singleQuoteStyle: SingleQuoteStyles.SmartQuote,
+        doubleQuoteStyle: DoubleQuoteStyles.SmartQuote,
+      },
+    },
   ],
 });