diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..cbf6139 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,12 @@ +name: Mirror to Forgejo +on: [push, delete] +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: | + git remote add forgejo https://dev.stephenbrough.com/StephenBrough/flutter_dotenv.git + git push --mirror https://username:${{ secrets.FORGEJO_MIRROR }}@dev.stephenbrough.com/StephenBrough/flutter_dotenv.git diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000..12c5b0a --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + - name: Run tests + run: flutter test diff --git a/example/assets/.env b/example/assets/.env index 407149c..f8095c2 100644 --- a/example/assets/.env +++ b/example/assets/.env @@ -28,4 +28,11 @@ RETAIN_TRAILING_SQUOTE=retained' RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' TRIM_SPACE_FROM_UNQUOTED= some spaced out string USERNAME=therealnerdybeast@example.tld - SPACED_KEY = parsed \ No newline at end of file + SPACED_KEY = parsed +MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u +LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ +bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ +kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V +u4QuUoobAgMBAAE= +-----END PUBLIC KEY-----" \ No newline at end of file diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 2a7ebe2..54459a8 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -3,9 +3,8 @@ class Parser { static const _singleQuote = "'"; static final _leadingExport = RegExp(r'''^ *export ?'''); - static final _comment = RegExp(r'''#[^'"]*$'''); - static final _commentWithQuotes = RegExp(r'''#.*$'''); - static final _surroundQuotes = RegExp(r'''^(["'])(.*?[^\\])\1'''); + static final _surroundQuotes = + RegExp(r'''^(["'])((?:\\.|(?!\1).)*)\1''', dotAll: true); static final _bashVar = RegExp(r'''(\\)?(\$)(?:{)?([a-zA-Z_][\w]*)+(?:})?'''); /// [Parser] methods are pure functions. @@ -15,10 +14,61 @@ class Parser { /// Duplicate keys are silently discarded. Map parse(Iterable lines) { var envMap = {}; - for (var line in lines) { - final parsedKeyValue = parseOne(line, envMap: envMap); - if (parsedKeyValue.isEmpty) continue; - envMap.putIfAbsent(parsedKeyValue.keys.single, () => parsedKeyValue.values.single); + var linesList = lines.toList(); + var i = 0; + + while (i < linesList.length) { + var line = linesList[i]; + + // Skip comments and empty lines + if (line.trim().startsWith('#') || line.trim().isEmpty) { + i++; + continue; + } + + // Handle multi-line values + if (line.contains('=')) { + var parts = line.split('='); + var key = trimExportKeyword(parts[0]).trim(); + var value = parts.sublist(1).join('=').trim(); + + // Check if this is a multi-line value + if ((value.startsWith('"') || value.startsWith("'")) && + !value.endsWith(value[0]) && + i < linesList.length - 1) { + var quoteChar = value[0]; + var nextLine = linesList[i + 1]; + // If next line is not empty and not a key=value pair, treat as multi-line + if (nextLine.trim().isNotEmpty && !nextLine.contains('=')) { + var buffer = StringBuffer(); + buffer.write(value.substring(1)); // Remove leading quote + i++; + var lines = []; + while (i < linesList.length) { + var currentLine = linesList[i]; + if (currentLine.trim().endsWith(quoteChar)) { + lines.add(currentLine.substring( + 0, + currentLine + .lastIndexOf(quoteChar))); // Remove trailing quote + break; + } + lines.add(currentLine); + i++; + } + // Join lines with Unix-style line endings + value = ('$buffer\n${lines.join('\n')}') + .replaceAll('\r\n', '\n') + .replaceAll('\r', '\n'); + } + } + + final parsedKeyValue = parseOne('$key=$value', envMap: envMap); + if (parsedKeyValue.isNotEmpty) { + envMap.putIfAbsent(key, () => parsedKeyValue.values.single); + } + } + i++; } return envMap; } @@ -30,37 +80,51 @@ class Parser { if (!_isStringWithEqualsChar(lineWithoutComments)) return {}; final indexOfEquals = lineWithoutComments.indexOf('='); - final envKey = trimExportKeyword(lineWithoutComments.substring(0, indexOfEquals)); + final envKey = + trimExportKeyword(lineWithoutComments.substring(0, indexOfEquals)); if (envKey.isEmpty) return {}; - final envValue = lineWithoutComments.substring(indexOfEquals + 1, lineWithoutComments.length).trim(); + final envValue = lineWithoutComments + .substring(indexOfEquals + 1, lineWithoutComments.length) + .trim(); final quoteChar = getSurroundingQuoteCharacter(envValue); var envValueWithoutQuotes = removeSurroundingQuotes(envValue); - // Add any escapted quotes + // Add any escaped quotes if (quoteChar == _singleQuote) { envValueWithoutQuotes = envValueWithoutQuotes.replaceAll("\\'", "'"); // Return. We don't expect any bash variables in single quoted strings return {envKey: envValueWithoutQuotes}; } if (quoteChar == '"') { - envValueWithoutQuotes = envValueWithoutQuotes.replaceAll('\\"', '"').replaceAll('\\n', '\n'); + envValueWithoutQuotes = envValueWithoutQuotes.replaceAll('\\"', '"'); } // Interpolate bash variables - final interpolatedValue = interpolate(envValueWithoutQuotes, envMap).replaceAll("\\\$", "\$"); + final interpolatedValue = + interpolate(envValueWithoutQuotes, envMap).replaceAll("\\\$", "\$"); return {envKey: interpolatedValue}; } /// Substitutes $bash_vars in [val] with values from [env]. - String interpolate(String val, Map env) => - val.replaceAllMapped(_bashVar, (m) { - if ((m.group(1) ?? "") == "\\") { - return m.input.substring(m.start, m.end); - } else { - final k = m.group(3)!; - if (!_has(env, k)) return ''; - return env[k]!; - } - }); + String interpolate(String val, Map env) { + // Handle variable substitution + return val.replaceAllMapped(_bashVar, (m) { + // If escaped with backslash, keep the $ but remove the backslash + if (m.group(1) != null) { + return '\$${m.group(3)}'; + } + + // Get the variable name + final varName = m.group(3)!; + + // If the variable exists in env, substitute its value + if (_has(env, varName)) { + return env[varName]!; + } + + // If variable doesn't exist, return empty string + return ''; + }); + } /// If [val] is wrapped in single or double quotes, returns the quote character. /// Otherwise, returns the empty string. @@ -71,18 +135,60 @@ class Parser { /// Removes quotes (single or double) surrounding a value. String removeSurroundingQuotes(String val) { - if (!_surroundQuotes.hasMatch(val)) { - return removeCommentsFromLine(val, includeQuotes: true).trim(); + var trimmed = val.trim(); + + // Handle values that start with a quote but don't end with one + if (trimmed.startsWith('"') && !trimmed.endsWith('"')) { + return trimmed; + } + if (trimmed.startsWith("'") && !trimmed.endsWith("'")) { + return trimmed; + } + // Handle values that end with a quote but don't start with one + if (trimmed.endsWith('"') && !trimmed.startsWith('"')) { + return trimmed; + } + if (trimmed.endsWith("'") && !trimmed.startsWith("'")) { + return trimmed; + } + + if (!_surroundQuotes.hasMatch(trimmed)) { + return removeCommentsFromLine(trimmed, includeQuotes: true).trim(); } - return _surroundQuotes.firstMatch(val)!.group(2)!; + final match = _surroundQuotes.firstMatch(trimmed)!; + var content = match.group(2)!; + // Only handle newlines for double-quoted strings + if (match.group(1) == '"') { + content = content.replaceAll('\\n', '\n').replaceAll(RegExp(r'\r?\n'), '\n'); + } + return content; } /// Strips comments (trailing or whole-line). - String removeCommentsFromLine(String line, {bool includeQuotes = false}) => - line.replaceAll(includeQuotes ? _commentWithQuotes : _comment, '').trim(); + String removeCommentsFromLine(String line, {bool includeQuotes = false}) { + var result = line; + // If we're including quotes in comment detection, remove everything after # + var commentIndex = result.indexOf('#'); + if (commentIndex >= 0 && !_isInQuotes(result, commentIndex)) { + result = result.substring(0, commentIndex); + } + return result.trim(); + } + + /// Checks if the character at the given index is inside quotes + bool _isInQuotes(String str, int index) { + var inSingleQuote = false; + var inDoubleQuote = false; + for (var i = 0; i < index; i++) { + if (str[i] == '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote; + if (str[i] == "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote; + } + return inSingleQuote || inDoubleQuote; + } /// Omits 'export' keyword. - String trimExportKeyword(String line) => line.replaceAll(_leadingExport, '').trim(); + String trimExportKeyword(String line) => + line.replaceAll(_leadingExport, '').trim(); bool _isStringWithEqualsChar(String s) => s.isNotEmpty && s.contains('='); diff --git a/test/.env b/test/.env index 1a8cde2..8ce5d6b 100644 --- a/test/.env +++ b/test/.env @@ -41,4 +41,22 @@ RETAIN_TRAILING_SQUOTE=retained' RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}' TRIM_SPACE_FROM_UNQUOTED= some spaced out string USERNAME=therealnerdybeast@example.tld - SPACED_KEY = parsed \ No newline at end of file + SPACED_KEY = parsed +MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u +LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ +bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ +kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V +u4QuUoobAgMBAAE= +-----END PUBLIC KEY-----" +MULTI_DOUBLE_QUOTED="THIS +IS +A +MULTILINE +STRING" + +MULTI_SINGLE_QUOTED='THIS +IS +A +MULTILINE +STRING' diff --git a/test/dotenv_test.dart b/test/dotenv_test.dart index 1867eaf..8f20528 100644 --- a/test/dotenv_test.dart +++ b/test/dotenv_test.dart @@ -47,21 +47,31 @@ void main() { expect(dotenv.env['TRIM_SPACE_FROM_UNQUOTED'], 'some spaced out string'); expect(dotenv.env['USERNAME'], 'therealnerdybeast@example.tld'); expect(dotenv.env['SPACED_KEY'], 'parsed'); + expect(dotenv.env['MULTI_PEM_DOUBLE_QUOTED'], '''-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u +LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/ +bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/ +kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V +u4QuUoobAgMBAAE= +-----END PUBLIC KEY-----'''); + expect(dotenv.env['MULTI_DOUBLE_QUOTED'],'THIS\nIS\nA\nMULTILINE\nSTRING'); + expect(dotenv.env['MULTI_SINGLE_QUOTED'],'THIS\nIS\nA\nMULTILINE\nSTRING'); + }); test( - 'when getting a vairable that is not in .env, we should get the fallback we defined', + 'when getting a variable that is not in .env, we should get the fallback we defined', () { expect(dotenv.get('FOO', fallback: 'bar'), 'foo'); expect(dotenv.get('COMMENTS', fallback: 'sample'), 'sample'); expect(dotenv.get('EQUAL_SIGNS', fallback: 'sample'), 'equals=='); }); test( - 'when getting a vairable that is not in .env, we should get an error thrown', + 'when getting a variable that is not in .env, we should get an error thrown', () { expect(() => dotenv.get('COMMENTS'), throwsAssertionError); }); test( - 'when getting a vairable using the nullable getter, we should get null if no fallback is defined', + 'when getting a variable using the nullable getter, we should get null if no fallback is defined', () { expect(dotenv.maybeGet('COMMENTS'), null); expect(dotenv.maybeGet('COMMENTS', fallback: 'sample'), 'sample'); diff --git a/test/parser_test.dart b/test/parser_test.dart index 221e1d6..fa771d5 100644 --- a/test/parser_test.dart +++ b/test/parser_test.dart @@ -17,22 +17,22 @@ void main() { out = psr.trimExportKeyword(' foo = bar export'); expect(out, equals('foo = bar export')); }); - test('it strips trailing comments', () { - var out = psr.strip( + var out = psr.removeCommentsFromLine( 'needs="explanation" # It was the year when they finally immanentized the Eschaton.'); expect(out, equals('needs="explanation"')); - out = psr.strip( + out = psr.removeCommentsFromLine( 'needs="explanation # It was the year when they finally immanentized the Eschaton." '); expect( out, equals( 'needs="explanation # It was the year when they finally immanentized the Eschaton."')); - out = psr.strip( + out = psr.removeCommentsFromLine( 'needs=explanation # It was the year when they finally immanentized the Eschaton."', includeQuotes: true); expect(out, equals('needs=explanation')); - out = psr.strip(' # It was the best of times, it was a waste of time.'); + out = psr.removeCommentsFromLine( + ' # It was the best of times, it was a waste of time.'); expect(out, isEmpty); }); test('it knows quoted # is not a comment', () { @@ -45,8 +45,7 @@ void main() { test('it handles quotes in a comment', () { // note terminal whitespace var sing = psr.parseOne("fruit = 'banana' # comments can be 'sneaky!' "); - var doub = - psr.parseOne('fruit = " banana" # comments can be "sneaky!" '); + var doub = psr.parseOne('fruit = " banana" # comments can be "sneaky!" '); var none = psr.parseOne('fruit = banana # comments can be "sneaky!" '); @@ -59,7 +58,6 @@ void main() { psr.parseOne('fruit = banana # I\'m a comment with a final "quote"'); expect(fail['fruit'], equals('banana')); }); - test('it handles unquoted values', () { var out = psr.removeSurroundingQuotes(' str '); expect(out, equals('str')); @@ -76,14 +74,12 @@ void main() { var out = psr.removeSurroundingQuotes("retained'"); expect(out, equals("retained'")); }); - // test('it handles escaped quotes within values', () { // Does not // var out = _psr.unquote('''\'val_with_\\"escaped\\"_\\'quote\\'s \''''); // expect(out, equals('''val_with_"escaped"_'quote's ''')); // out = _psr.unquote(" val_with_\"escaped\"_\'quote\'s "); // expect(out, equals('''val_with_"escaped"_'quote's''')); // }); - test('it skips empty lines', () { var out = psr.parse([ '# Define environment variables.', @@ -94,7 +90,6 @@ void main() { ]); expect(out, equals({'foo': 'bar', 'baz': 'qux'})); }); - test('it ignores duplicate keys', () { var out = psr.parse(['foo=bar', 'foo=baz']); expect(out, equals({'foo': 'bar'})); @@ -107,7 +102,6 @@ void main() { var out = psr.parse([r"foo = 'bar'", r'export baz="qux"']); expect(out, equals({'foo': 'bar', 'baz': 'qux'})); }); - test('it detects unquoted values', () { var out = psr.getSurroundingQuoteCharacter('no quotes here!'); expect(out, isEmpty); @@ -120,7 +114,6 @@ void main() { var out = psr.getSurroundingQuoteCharacter("'single quoted'"); expect(out, equals("'")); }); - test('it performs variable substitution', () { var out = psr.interpolate(r'a$foo$baz', {'foo': 'bar', 'baz': 'qux'}); expect(out, equals('abarqux')); @@ -140,7 +133,6 @@ void main() { var out = psr.interpolate('optional_\${foo_$r}', {'foo_$r': 'curlies'}); expect(out, equals('optional_curlies')); }); - test('it handles equal signs in values', () { var none = psr.parseOne('foo=bar=qux'); var sing = psr.parseOne("foo='bar=qux'"); @@ -150,15 +142,16 @@ void main() { expect(sing['foo'], equals('bar=qux')); expect(doub['foo'], equals('bar=qux')); }); - test('it skips var substitution in single quotes', () { var r = rand.nextInt(ceil); // avoid runtime collision with real env vars - var out = psr.parseOne("some_var='my\$key_$r'", envMap: {'key_$r': 'val'}); + var out = + psr.parseOne("some_var='my\$key_$r'", envMap: {'key_$r': 'val'}); expect(out['some_var'], equals('my\$key_$r')); }); test('it performs var subs in double quotes', () { var r = rand.nextInt(ceil); // avoid runtime collision with real env vars - var out = psr.parseOne('some_var="my\$key_$r"', envMap: {'key_$r': 'val'}); + var out = + psr.parseOne('some_var="my\$key_$r"', envMap: {'key_$r': 'val'}); expect(out['some_var'], equals('myval')); }); test('it performs var subs without quotes', () { @@ -166,5 +159,22 @@ void main() { var out = psr.parseOne("some_var=my\$key_$r", envMap: {'key_$r': 'val'}); expect(out['some_var'], equals('myval')); }); + test('it parses multi-line strings when using double quotes', () { + var out = psr.parseOne('''MULTI_DOUBLE_QUOTED="THIS +IS +A +MULTILINE +STRING"'''); + expect(out['MULTI_DOUBLE_QUOTED'], equals('THIS\nIS\nA\nMULTILINE\nSTRING')); + }); + + test('it parses multi-line strings when using single quotes', () { + var out = psr.parseOne("""MULTI_SINGLE_QUOTED='THIS +IS +A +MULTILINE +STRING'"""); + expect(out['MULTI_SINGLE_QUOTED'], equals('THIS\nIS\nA\nMULTILINE\nSTRING')); + }); }); }