Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion example/assets/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
SPACED_KEY = parsed
MULTI_PEM_DOUBLE_QUOTED="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnNl1tL3QjKp3DZWM0T3u
LgGJQwu9WqyzHKZ6WIA5T+7zPjO1L8l3S8k8YzBrfH4mqWOD1GBI8Yjq2L1ac3Y/
bTdfHN8CmQr2iDJC0C6zY8YV93oZB3x0zC/LPbRYpF8f6OqX1lZj5vo2zJZy4fI/
kKcI5jHYc8VJq+KCuRZrvn+3V+KuL9tF9v8ZgjF2PZbU+LsCy5Yqg1M8f5Jp5f6V
u4QuUoobAgMBAAE=
-----END PUBLIC KEY-----"
162 changes: 134 additions & 28 deletions lib/src/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -15,10 +14,61 @@ class Parser {
/// Duplicate keys are silently discarded.
Map<String, String> parse(Iterable<String> lines) {
var envMap = <String, String>{};
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 = <String>[];
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;
}
Expand All @@ -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<String, String?> 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<String, String?> 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.
Expand All @@ -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('=');

Expand Down
20 changes: 19 additions & 1 deletion test/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
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'
16 changes: 13 additions & 3 deletions test/dotenv_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading