regex.mjs

/**
 * @module regex
 */

/**
 * Regular expression for matching a YouTube video links and extracting their ID, works with both embed and watch URLs
 */
export const RE_YOUTUBE = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/i

/**
 * Regular expression for matching a Vimeo video links and extracting their ID, works with both embed and watch URLs, channels and groups
 */
export const RE_VIMEO = /(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/(?:[^\/]*)\/videos\/|album\/(?:\d+)\/video\/|video\/|)(\d+)(?:[a-zA-Z0-9_\-]+)?/i

/**
 * Regular expression for matching a video URLs, tolerates a trailing query string or hash
 */
export const RE_VIDEO = /\/([^\/?#]+\.(?:mp4|m4v|ogg|ogv|ogm|webm|avi|mov|qt))(?:[?#][^\/]*)?\s*$/i

/**
 * Regular expression for matching a image URLs
 */
export const RE_IMAGE = /\/([^\/]+\.(?:jpg|jpeg|png|gif|svg|webp))\s*$/i

/**
 * Regular expression for matching a URL parameters
 */
export const RE_URL_PARAMETER = /([^\s=&]+)=?([^&\s]+)?/

/**
 * Regular expression for matching a HTML attribute and tag names, also for matching shortcode attributes and names
 */
export const RE_ATTRIBUTES = /\s*(?:([a-z_]{1}[a-z0-9\-_]*)=?(?:"([^"]+)"|'([^']+)')*)\s*/gi

/**
 * Regular expression for matching a single attribute without value
 */
export const RE_ATTRIBUTE_WITHOUT_VALUE = /^\s*([a-z_]{1}[a-z0-9\-_]*)\s*$/i

/**
 * Regular expression for matching a single attribute with value
 */
export const RE_ATTRIBUTE_WITH_VALUE = /^\s*([a-z_]{1}[a-z0-9\-_]*)=("[^"]+"|'[^']+')\s*$/i

/**
 * Regular expression for matching the first or last quote of a string used for removing them
 */
export const RE_FIRST_OR_LAST_QUOTE = /^["']|["']$/g

/**
 * Escape special characters in a string to be used in a regular expression
 * @param {string} string The string to escape
 * @returns {string} The escaped string
 * @example
 * escapeRegExp('hello world') // 'hello world'
 * escapeRegExp('hello.*+?^${}()|[]\\world') // 'hello\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\world'
 */
export function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

/**
 * Find the index of the closing parenthesis matching the opening one at the given index
 * @ignore
 * @param {string} str The string to search
 * @param {number} openIndex The index of the opening parenthesis
 * @returns {number} The index of the matching closing parenthesis, or -1 if not found
 */
function findMatchingParen(str, openIndex) {
  let depth = 1
  for (let i = openIndex + 1; i < str.length; i++) {
    if (str[i] === '(') depth++
    if (str[i] === ')') depth--
    if (depth === 0) return i
  }
  return -1
}

/**
 * Extract all !() negation extglob patterns from a glob string
 * @ignore
 * @param {string} glob The glob pattern
 * @returns {Array<{start: number, end: number, inner: string}>} Array of negation descriptors with start/end indices and inner content
 */
function extractNegatedGlobs(glob) {
  const negations = []
  for (let i = 0; i < glob.length; i++) {
    if (glob[i] !== '!' || glob[i + 1] !== '(') continue
    const closeIndex = findMatchingParen(glob, i + 1)
    if (closeIndex === -1) continue
    negations.push({ start: i, end: closeIndex, inner: glob.slice(i + 2, closeIndex) })
    i = closeIndex
  }
  return negations
}

/**
 * Build a glob variant by replacing !() negations — one is expanded to @() for matching, the rest become * wildcards
 * @ignore
 * @param {string} glob The original glob pattern
 * @param {Array<{start: number, end: number, inner: string}>} negations The negation descriptors from extractNegatedGlobs
 * @param {number} expandIndex The index of the negation to expand as @(), or -1 to replace all with *
 * @returns {string} The modified glob string
 */
function buildGlobVariant(glob, negations, expandIndex) {
  let result = ''
  let lastEnd = 0
  negations.forEach((neg, idx) => {
    result += glob.slice(lastEnd, neg.start)
    result += idx === expandIndex ? '@(' + neg.inner + ')' : '*'
    lastEnd = neg.end + 1
  })
  result += glob.slice(lastEnd)
  return result
}

/**
 * Handle !() negation extglob patterns by building a positive regex and negative regexes, 
 * returning an object with a test method that matches the positive but rejects the negatives
 * @ignore
 * @param {string} glob The glob pattern containing !() negations
 * @param {boolean} [anchored=false] Whether to anchor the regex with ^ and $ for exact matching
 * @returns {{test: function}|null} An object with a test method, or null if no negations are found
 */
function convertGlobNegationsToRegex(glob, anchored = false) {
  const negations = extractNegatedGlobs(glob)
  if (negations.length === 0) return null

  const positiveGlob = buildGlobVariant(glob, negations, -1)
  const positiveRegex = convertGlobToRegex(positiveGlob, anchored)
  if (!positiveRegex) return null

  const negativeRegexes = negations.map((_, idx) => {
    return convertGlobToRegex(buildGlobVariant(glob, negations, idx), anchored)
  }).filter(Boolean)

  if (negativeRegexes.length === 0) return positiveRegex

  return {
    test(str) {
      if (anchored) {
        if (!new RegExp('^' + positiveRegex.source + '$').test(str)) return false
        return negativeRegexes.every(re => !new RegExp('^' + re.source + '$').test(str))
      }
      const globalRe = new RegExp(positiveRegex.source, 'g')
      let match
      while ((match = globalRe.exec(str)) !== null) {
        if (negativeRegexes.every(re => !new RegExp('^' + re.source + '$').test(match[0]))) {
          return true
        }
        if (match[0].length === 0) globalRe.lastIndex++
      }
      return false
    }
  }
}

/**
 * Convert a glob pattern to a regex source string by parsing glob syntax character by character
 * @ignore
 * @param {string} glob The glob pattern to convert
 * @param {boolean} [anchored=false] Whether to anchor the regex with ^ and $ for exact matching
 * @returns {string} The regex source string
 */
function convertGlobToRegexString(glob, anchored = false) {
  let regexString = ''
  let insideSquareBrackets = false
  let insideCurlyBraces = false
  const extglobStack = []

  for (let i = 0; i < glob.length; i++) {
    const char = glob[i]
    const next = glob[i + 1]

    // Glob escape: \x → literal x
    if (char === '\\' && i + 1 < glob.length) {
      regexString += '\\' + next
      i++
      continue
    }

    // Inside character class [...]
    if (insideSquareBrackets) {
      if (char === ']') {
        insideSquareBrackets = false
        regexString += ']'
      } else if (char === '!') {
        regexString += '^'
      } else {
        regexString += char
      }
      continue
    }

    // Extended glob: ?(, *(, +(, @(
    if (next === '(' && '?*+@'.includes(char)) {
      extglobStack.push(char)
      regexString += '(?:'
      i++
      continue
    }

    // Closing ) for extglob
    if (char === ')' && extglobStack.length > 0) {
      const type = extglobStack.pop()
      const suffixes = { '?': ')?', '*': ')*', '+': ')+', '@': ')' }
      regexString += suffixes[type]
      continue
    }

    // ** globstar — matches across path separators
    if (char === '*' && next === '*') {
      regexString += '.*'
      i++
      if (glob[i + 1] === '/') i++
      continue
    }

    // * — matches within a single path segment
    if (char === '*') { regexString += '[^\\/\\\\]*'; continue }

    // ? — single non-separator character
    if (char === '?') { regexString += '[^\\/\\\\]'; continue }

    // Character class
    if (char === '[') { insideSquareBrackets = true; regexString += '['; continue }

    // Brace expansion
    if (char === '{') { insideCurlyBraces = true; regexString += '('; continue }
    if (char === '}') { insideCurlyBraces = false; regexString += ')'; continue }
    if (char === ',' && insideCurlyBraces) { regexString += '|'; continue }

    // Pipe is alternation inside extglob, literal outside
    if (char === '|' && extglobStack.length > 0) { regexString += '|'; continue }

    // Escape regex-special characters
    if ('.|+$^(){}\\/-'.includes(char)) { regexString += '\\' + char; continue }

    regexString += char
  }

  if (anchored) {
    regexString = '^' + regexString + '$'
  }

  return regexString
}

/**
 * Convert a glob pattern to a regular expression but unanchored (doesn't add ^ or $) by default
 *
 * Supports standard glob syntax (*, **, ?, [...], {...}), extended glob patterns
 * (?(pattern), *(pattern), +(pattern), @(pattern), !(pattern)), and backslash escaping.
 * 
 * Alternative packages to consider: micromatch, picomatch, minimatch, glob-to-regexp...
 *
 * @param {string} glob - The glob pattern to convert
 * @param {boolean} [anchored=false] - Whether to anchor the regex with ^ and $ for exact matching
 * @returns {RegExp|{test: function}|null} A RegExp (or object with test method for negation 
 * patterns), or null if the pattern is invalid
 * @example
 * convertGlobToRegex('*.js').test('app.js') // true
 * convertGlobToRegex('src/**\/*.ts').test('src/lib/utils.ts') // true
 * convertGlobToRegex('*.{js,ts}').test('app.ts') // true
 * convertGlobToRegex('!(test).js').test('app.js') // true
 * convertGlobToRegex('!(test).js').test('test.js') // false
 */
export function convertGlobToRegex(glob, anchored = false) {
  const negations = convertGlobNegationsToRegex(glob, anchored)
  if (negations) return negations

  const regexString = convertGlobToRegexString(glob, anchored)
  if (!regexString || regexString.length === 0) return null
  
  try {
    return new RegExp(regexString)
  } catch (e) {
    return null
  }
}