Skip to content

Commit

Permalink
Merge pull request #108 from motdotla/one-two
Browse files Browse the repository at this point in the history
handle `$var1$var2` syntax
  • Loading branch information
motdotla committed Feb 10, 2024
2 parents e517bc5 + 315436b commit c7cead7
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 80 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. See [standa

## [Unreleased](https://github.com/motdotla/dotenv-expand/compare/v11.0.0...master)

## [11.0.0](https://github.com/motdotla/dotenv-expand/compare/v10.0.0...v11.0.0) (2024-02-08)
## [11.0.0](https://github.com/motdotla/dotenv-expand/compare/v10.0.0...v11.0.0) (2024-02-10)

### Added

Expand All @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. See [standa
### Changed

- Do not expand prior `process.env` environment variables. NOTE: make sure to see updated README regarding `dotenv.config({ processEnv: {} })` ([#104](https://github.com/motdotla/dotenv-expand/pull/104))
- 🐞 handle `$var1$var2` ([#103](https://github.com/motdotla/dotenv-expand/issues/103), [#104](https://github.com/motdotla/dotenv-expand/pull/104))

### Removed

Expand Down
134 changes: 85 additions & 49 deletions lib/main.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,93 @@
'use strict'

// like String.prototype.search but returns the last index
function _searchLast (str, rgx) {
const matches = Array.from(str.matchAll(rgx))
return matches.length > 0 ? matches.slice(-1)[0].index : -1
}

function _interpolate (value, processEnv, parsed) {
// find the last unescaped dollar sign in the value to evaluate
const lastUnescapedDollarSignIndex = _searchLast(value, /(?!(?<=\\))\$/g)

// return early unless unescaped dollar sign
if (lastUnescapedDollarSignIndex === -1) {
return value
}

// This is the right-most group of variables in the string
const rightMostGroup = value.slice(lastUnescapedDollarSignIndex)

/**
* This finds the inner most variable/group divided
* by variable name and default value (if present)
* (
* (?!(?<=\\))\$ // only match dollar signs that are not escaped
* {? // optional opening curly brace
* ([\w.]+) // match the variable name
* (?::-([^}\\]*))? // match an optional default value
* }? // optional closing curly brace
* )
*/
const matchGroup = /((?!(?<=\\))\${?([\w.]+)(?::-([^}\\]*))?}?)/
const match = rightMostGroup.match(matchGroup)

if (match != null) {
const [, group, key, defaultValue] = match
const replacementString = processEnv[key] || defaultValue || parsed[key] || ''
const modifiedValue = value.replace(group, replacementString)

// return early for scenario like processEnv.PASSWORD = 'pas$word'
if (processEnv[key] && modifiedValue === processEnv[key]) {
return modifiedValue
}

return _interpolate(modifiedValue, processEnv, parsed)
}

return value
}
// // like String.prototype.search but returns the last index
// function _searchLast (str, rgx) {
// const matches = Array.from(str.matchAll(rgx))
// return matches.length > 0 ? matches.slice(-1)[0].index : -1
// }
//
// function _interpolate (value, processEnv, parsed) {
// // find the last unescaped dollar sign in the value to evaluate
// const lastUnescapedDollarSignIndex = _searchLast(value, /(?!(?<=\\))\$/g)
//
// // return early unless unescaped dollar sign
// if (lastUnescapedDollarSignIndex === -1) {
// return value
// }
//
// // This is the right-most group of variables in the string
// const rightMostGroup = value.slice(lastUnescapedDollarSignIndex)
//
// console.log('rightMostGroup', rightMostGroup)
//
// /**
// * This finds the inner most variable/group divided
// * by variable name and default value (if present)
// * (
// * (?!(?<=\\))\$ // only match dollar signs that are not escaped
// * {? // optional opening curly brace
// * ([\w.]+) // match the variable name
// * (?::-([^}\\]*))? // match an optional default value
// * }? // optional closing curly brace
// * )
// */
// const matchGroup = /((?!(?<=\\))\${?([\w.]+)(?::-([^}\\]*))?}?)/
// const match = rightMostGroup.match(matchGroup)
//
// if (match != null) {
// const [, group, key, defaultValue] = match
// const replacementString = processEnv[key] || defaultValue || parsed[key] || ''
// const modifiedValue = value.replace(group, replacementString)
//
// // return early for scenario like processEnv.PASSWORD = 'pas$word'
// if (processEnv[key] && modifiedValue === processEnv[key]) {
// return modifiedValue
// }
//
// return _interpolate(modifiedValue, processEnv, parsed)
// }
//
// return value
// }

function _resolveEscapeSequences (value) {
return value.replace(/\\\$/g, '$')
}

function interpolate (value, processEnv, parsed) {
// * /
// * (\\)? # is it escaped with a backslash?
// * (\$) # literal $
// * (?!\() # shouldnt be followed by parenthesis
// * (\{?) # first brace wrap opening
// * ([\w.]+) # key
// * (?::-((?:\$\{(?:\$\{(?:\$\{[^}]*\}|[^}])*}|[^}])*}|[^}])+))? # optional default nested 3 times
// * (\}?) # last brace warp closing
// * /xi

const SUB_REGEX = /(\\)?(\$)(?!\()(\{?)([\w.]+)(?::-((?:\$\{(?:\$\{(?:\$\{[^}]*\}|[^}])*}|[^}])*}|[^}])+))?(\}?)/gi

return value.replace(SUB_REGEX, (match, escaped, dollarSign, openBrace, key, defaultValue, closeBrace) => {
if (escaped === '\\') {
return match.slice(1)
} else {
if (processEnv[key]) {
return processEnv[key]
}

if (defaultValue) {
if (defaultValue.startsWith('$')) {
return interpolate(defaultValue, processEnv, parsed)
} else {
return defaultValue
}
}

return parsed[key] || ''
}
})
}

function expand (options) {
let processEnv = process.env
if (options && options.processEnv != null) {
Expand All @@ -61,11 +97,11 @@ function expand (options) {
for (const key in options.parsed) {
let value = options.parsed[key]

// don't interpolate the processEnv value if it exists there already
// don't interpolate if it exists already in processEnv
if (Object.prototype.hasOwnProperty.call(processEnv, key)) {
value = processEnv[key]
} else {
value = _interpolate(value, processEnv, options.parsed)
value = interpolate(value, processEnv, options.parsed)
}

options.parsed[key] = _resolveEscapeSequences(value)
Expand Down
42 changes: 26 additions & 16 deletions tests/.env.test
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
NODE_ENV=test
BASIC=basic
BASIC_EXPAND=$BASIC
MACHINE=machine_env
MACHINE_EXPAND=$MACHINE
UNDEFINED_EXPAND=$UNDEFINED_ENV_KEY

ESCAPED_EXPAND=\$ESCAPED
DEFINED_EXPAND_WITH_DEFAULT=${MACHINE:-default}
DEFINED_EXPAND_WITH_DEFAULT_NESTED=${MACHINE:-${UNDEFINED_ENV_KEY:-default}}
UNDEFINED_EXPAND_WITH_DEFINED_NESTED=${UNDEFINED_ENV_KEY:-${MACHINE:-default}}
UNDEFINED_EXPAND_WITH_DEFAULT=${UNDEFINED_ENV_KEY:-default}
UNDEFINED_EXPAND_WITH_DEFAULT_NESTED=${UNDEFINED_ENV_KEY:-${UNDEFINED_ENV_KEY_2:-default}}
DEFINED_EXPAND_WITH_DEFAULT_NESTED_TWICE=${UNDEFINED_ENV_KEY:-${MACHINE}${UNDEFINED_ENV_KEY_3:-default}}
UNDEFINED_EXPAND_WITH_DEFAULT_NESTED_TWICE=${UNDEFINED_ENV_KEY:-${UNDEFINED_ENV_KEY_2:-${UNDEFINED_ENV_KEY_3:-default}}}
DEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS=${MACHINE:-/default/path:with/colon}
UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS=${UNDEFINED_ENV_KEY:-/default/path:with/colon}
UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS_NESTED=${UNDEFINED_ENV_KEY:-${UNDEFINED_ENV_KEY_2:-/default/path:with/colon}}

EXPAND_DEFAULT=${MACHINE:-default}
EXPAND_DEFAULT_NESTED=${MACHINE:-${UNDEFINED:-default}}
EXPAND_DEFAULT_NESTED_TWICE=${UNDEFINED:-${MACHINE}${UNDEFINED:-default}}
EXPAND_DEFAULT_SPECIAL_CHARACTERS=${MACHINE:-/default/path:with/colon}

UNDEFINED_EXPAND=$UNDEFINED
UNDEFINED_EXPAND_NESTED=${UNDEFINED:-${MACHINE:-default}}
UNDEFINED_EXPAND_DEFAULT=${UNDEFINED:-default}
UNDEFINED_EXPAND_DEFAULT_NESTED=${UNDEFINED:-${UNDEFINED:-default}}
UNDEFINED_EXPAND_DEFAULT_NESTED_TWICE=${UNDEFINED:-${UNDEFINED:-${UNDEFINED:-default}}}
UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS=${UNDEFINED:-/default/path:with/colon}
UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS_NESTED=${UNDEFINED:-${UNDEFINED_2:-/default/path:with/colon}}

MONGOLAB_DATABASE=heroku_db
MONGOLAB_USER=username
MONGOLAB_PASSWORD=password
Expand All @@ -25,12 +28,19 @@ MONGOLAB_URI=mongodb://${MONGOLAB_USER}:${MONGOLAB_PASSWORD}@${MONGOLAB_DOMAIN}:
MONGOLAB_USER_RECURSIVELY=${MONGOLAB_USER}:${MONGOLAB_PASSWORD}
MONGOLAB_URI_RECURSIVELY=mongodb://${MONGOLAB_USER_RECURSIVELY}@${MONGOLAB_DOMAIN}:${MONGOLAB_PORT}/${MONGOLAB_DATABASE}

WITHOUT_CURLY_BRACES_URI=mongodb://$MONGOLAB_USER:$MONGOLAB_PASSWORD@$MONGOLAB_DOMAIN:$MONGOLAB_PORT/$MONGOLAB_DATABASE
WITHOUT_CURLY_BRACES_USER_RECURSIVELY=$MONGOLAB_USER:$MONGOLAB_PASSWORD
WITHOUT_CURLY_BRACES_URI_RECURSIVELY=mongodb://$MONGOLAB_USER_RECURSIVELY@$MONGOLAB_DOMAIN:$MONGOLAB_PORT/$MONGOLAB_DATABASE
WITHOUT_CURLY_BRACES_UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS=$UNDEFINED_ENV_KEY:-/default/path:with/colon
NO_CURLY_BRACES_URI=mongodb://$MONGOLAB_USER:$MONGOLAB_PASSWORD@$MONGOLAB_DOMAIN:$MONGOLAB_PORT/$MONGOLAB_DATABASE
NO_CURLY_BRACES_USER_RECURSIVELY=$MONGOLAB_USER:$MONGOLAB_PASSWORD
NO_CURLY_BRACES_URI_RECURSIVELY=mongodb://$MONGOLAB_USER_RECURSIVELY@$MONGOLAB_DOMAIN:$MONGOLAB_PORT/$MONGOLAB_DATABASE
NO_CURLY_BRACES_UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS=$UNDEFINED:-/default/path:with/colon

POSTGRESQL.BASE.USER=postgres
POSTGRESQL.MAIN.USER=${POSTGRESQL.BASE.USER}

DOLLAR=$

ONE=one
TWO=two
ONETWO=${ONE}${TWO}
ONETWO_SIMPLE=${ONE}$TWO
ONETWO_SIMPLE2=$ONE${TWO}
ONETWO_SUPER_SIMPLE=$ONE$TWO
40 changes: 26 additions & 14 deletions tests/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ t.test('does not expand environment variables existing already on the machine th
t.test('expands missing environment variables to an empty string', ct => {
const dotenv = {
parsed: {
UNDEFINED_EXPAND: '$UNDEFINED_ENV_KEY'
UNDEFINED_EXPAND: '$UNDEFINED'
}
}
const parsed = dotenvExpand.expand(dotenv).parsed
Expand Down Expand Up @@ -194,7 +194,7 @@ t.test('expands environment variables existing already on the machine even with
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
dotenvExpand.expand(dotenv)

ct.equal(process.env.DEFINED_EXPAND_WITH_DEFAULT, 'machine')
ct.equal(process.env.EXPAND_DEFAULT, 'machine')

ct.end()
})
Expand All @@ -205,7 +205,7 @@ t.test('expands environment variables existing already on the machine even with
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
dotenvExpand.expand(dotenv)

ct.equal(process.env.DEFINED_EXPAND_WITH_DEFAULT_NESTED, 'machine')
ct.equal(process.env.EXPAND_DEFAULT_NESTED, 'machine')

ct.end()
})
Expand All @@ -216,7 +216,7 @@ t.test('expands environment variables undefined with one already on the machine
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
dotenvExpand.expand(dotenv)

ct.equal(process.env.UNDEFINED_EXPAND_WITH_DEFINED_NESTED, 'machine')
ct.equal(process.env.UNDEFINED_EXPAND_NESTED, 'machine')

ct.end()
})
Expand All @@ -225,7 +225,7 @@ t.test('expands missing environment variables to an empty string but replaces wi
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT, 'default')
ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT, 'default')

ct.end()
})
Expand All @@ -234,7 +234,7 @@ t.test('expands environent variables and concats with default nested', ct => {
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.DEFINED_EXPAND_WITH_DEFAULT_NESTED_TWICE, 'machinedefault')
ct.equal(parsed.EXPAND_DEFAULT_NESTED_TWICE, 'machinedefault')

ct.end()
})
Expand All @@ -243,7 +243,7 @@ t.test('expands missing environment variables to an empty string but replaces wi
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT_NESTED, 'default')
ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_NESTED, 'default')

ct.end()
})
Expand All @@ -252,7 +252,7 @@ t.test('expands missing environment variables to an empty string but replaces wi
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT_NESTED_TWICE, 'default')
ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_NESTED_TWICE, 'default')

ct.end()
})
Expand Down Expand Up @@ -290,7 +290,7 @@ t.test('multiple expand', ct => {
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.WITHOUT_CURLY_BRACES_URI, 'mongodb://username:password@abcd1234.mongolab.com:12345/heroku_db')
ct.equal(parsed.NO_CURLY_BRACES_URI, 'mongodb://username:password@abcd1234.mongolab.com:12345/heroku_db')

ct.end()
})
Expand All @@ -299,7 +299,7 @@ t.test('should expand recursively', ct => {
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.WITHOUT_CURLY_BRACES_URI_RECURSIVELY, 'mongodb://username:password@abcd1234.mongolab.com:12345/heroku_db')
ct.equal(parsed.NO_CURLY_BRACES_URI_RECURSIVELY, 'mongodb://username:password@abcd1234.mongolab.com:12345/heroku_db')

ct.end()
})
Expand All @@ -326,7 +326,7 @@ t.test('expands environment variables existing already on the machine even with
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.DEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS, 'machine')
ct.equal(parsed.EXPAND_DEFAULT_SPECIAL_CHARACTERS, 'machine')

ct.end()
})
Expand All @@ -335,8 +335,8 @@ t.test('should expand with default value correctly', ct => {
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS, '/default/path:with/colon')
ct.equal(parsed.WITHOUT_CURLY_BRACES_UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS, '/default/path:with/colon')
ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS, '/default/path:with/colon')
ct.equal(parsed.NO_CURLY_BRACES_UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS, '/default/path:with/colon')

ct.end()
})
Expand All @@ -345,7 +345,7 @@ t.test('should expand with default nested value correctly', ct => {
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.UNDEFINED_EXPAND_WITH_DEFAULT_WITH_SPECIAL_CHARACTERS_NESTED, '/default/path:with/colon')
ct.equal(parsed.UNDEFINED_EXPAND_DEFAULT_SPECIAL_CHARACTERS_NESTED, '/default/path:with/colon')

ct.end()
})
Expand All @@ -367,3 +367,15 @@ t.test('handles value of only $', ct => {

ct.end()
})

t.test('handles $one$two', ct => {
const dotenv = require('dotenv').config({ path: 'tests/.env.test', processEnv: {} })
const parsed = dotenvExpand.expand(dotenv).parsed

ct.equal(parsed.ONETWO, 'onetwo')
ct.equal(parsed.ONETWO_SIMPLE, 'onetwo')
ct.equal(parsed.ONETWO_SIMPLE2, 'onetwo')
ct.equal(parsed.ONETWO_SUPER_SIMPLE, 'onetwo')

ct.end()
})

0 comments on commit c7cead7

Please sign in to comment.