Skip to content

Commit

Permalink
Merge pull request #101 from MrDave1999/feature/issue_80
Browse files Browse the repository at this point in the history
Added support for multi-line values
  • Loading branch information
MrDave1999 committed Jun 30, 2022
2 parents 601760b + ffb9a43 commit 8e9e285
Show file tree
Hide file tree
Showing 14 changed files with 300 additions and 28 deletions.
5 changes: 5 additions & 0 deletions example/.env
@@ -1,2 +1,7 @@
MYSQL_HOST=mysql
PRIVATE_KEY="[-----BEGIN RSA PRIVATE KEY-----
...
Kh9NV...
...
-----END DSA PRIVATE KEY-----]"
MYSQL_DB=test
1 change: 1 addition & 0 deletions example/Program.cs
Expand Up @@ -6,6 +6,7 @@
.Load();
Console.WriteLine($"MYSQL_HOST={reader["MYSQL_HOST"]}");
Console.WriteLine($"MYSQL_DB={reader["MYSQL_DB"]}");
Console.WriteLine($"PRIVATE_KEY={reader["PRIVATE_KEY"]}");
Console.WriteLine("\n\n\n");

Console.WriteLine("---- SPECIAL EXAMPLE:");
Expand Down
2 changes: 2 additions & 0 deletions src/Constants/ExceptionMessages.cs
Expand Up @@ -23,5 +23,7 @@ public class ExceptionMessages
public const string PropertyDoesNotMatchConfigKeyMessage = "The '{0}.{1}' property does not match any configuration key.";
public const string KeyAssignedToPropertyIsNotSetMessage = "Could not set the value in the '{0}.{1}' property because the '{2}' key is not set.";
public const string FailedConvertConfigurationValueMessage = "Failed to convert configuration value of '{0}' to type '{1}'. '{2}' is not a valid value for {3}.";
public const string LineHasNoEndDoubleQuoteMessage = "The line must end with a double-quoted at the end.";
public const string LineHasNoEndSingleQuoteMessage = "The line must end with a single-quoted at the end.";
}
}
21 changes: 21 additions & 0 deletions src/Extensions/StringExtensions.cs
Expand Up @@ -15,5 +15,26 @@ internal static class StringExtensions
/// <returns>An array that contains at most count substrings from this instance that are delimited by separator.</returns>
public static string[] Split(this string str, char separator, int count)
=> str.Split(new[] { separator }, count);

/// <summary>
/// Determines whether this string instance starts with the specified character.
/// </summary>
/// <param name="str"></param>
/// <param name="value">The character to compare.</param>
/// <returns>true if value matches the beginning of this string; otherwise, false.</returns>
public static bool StartsWith(this string str, char value)
=> str.Length != 0 && str[0] == value;

/// <summary>
/// Determines whether the end of this string instance matches the specified character.
/// </summary>
/// <param name="str"></param>
/// <param name="value">The character to compare to the character at the end of this instance.</param>
/// <returns>true if value matches the end of this instance; otherwise, false.</returns>
public static bool EndsWith(this string str, char value)
{
int len = str.Length;
return len != 0 && str[len - 1] == value;
}
}
}
6 changes: 6 additions & 0 deletions src/Helpers/FormattingMessage.cs
Expand Up @@ -40,6 +40,12 @@ public static string FormatParserExceptionMessage(string message, object actualV
if(AreNotNull(lineNumber, column, actualValue))
return $"Parsing error (line {lineNumber}, col {column}): error: {string.Format(message, actualValue)}";

if(AreNotNull(envFileName, lineNumber, column))
return $"{envFileName}:(line {lineNumber}, col {column}): error: {message}";

if(AreNotNull(lineNumber, column))
return $"Parsing error (line {lineNumber}, col {column}): error: {message}";

if (envFileName is not null)
return $"{envFileName}: error: {message}";

Expand Down
65 changes: 62 additions & 3 deletions src/Parser/EnvParser.HelperMethods.cs
Expand Up @@ -184,8 +184,8 @@ private bool IsQuoted(string text)
{
if(text.Length <= 1)
return false;
return (text.StartsWith("\"") && text.EndsWith("\""))
|| (text.StartsWith("'") && text.EndsWith("'"));
return (text.StartsWith(DoubleQuote) && text.EndsWith(DoubleQuote))
|| (text.StartsWith(SingleQuote) && text.EndsWith(SingleQuote));
}

/// <summary>
Expand All @@ -194,7 +194,7 @@ private bool IsQuoted(string text)
/// <param name="text">The text with quotes to remove.</param>
/// <returns>A string without single or double quotes.</returns>
private string RemoveQuotes(string text)
=> text.Trim(new[] { '\'', '"' });
=> text.Trim(new[] { SingleQuote, DoubleQuote });

/// <summary>
/// Removes the prefix before the key.
Expand All @@ -208,5 +208,64 @@ private string RemovePrefixBeforeKey(string key, string prefix)
key = key.TrimStart();
return key.IndexOf(prefix) == 0 ? key.Remove(0, prefix.Length) : aux;
}

/// <summary>
/// Checks if the value of a key is in multi-lines.
/// </summary>
/// <param name="value">The value to validate.</param>
/// <returns>
/// <c>true</c> if the <c>value</c> of a key is in multi-lines, or <c>false</c>.
/// </returns>
private bool IsMultiline(string value)
{
value = value.Trim();
if(value.Length == 0)
return false;

if(value.Length == 1 && value[0] is DoubleQuote or SingleQuote)
return true;

return (value.StartsWith(DoubleQuote) && !value.EndsWith(DoubleQuote))
|| (value.StartsWith(SingleQuote) && !value.EndsWith(SingleQuote));
}

/// <summary>
/// Gets the values of several lines of a key.
/// </summary>
/// <param name="lines"></param>
/// <param name="index">Contains the index of a line.</param>
/// <param name="value">The value of a key.</param>
/// <returns>
/// A string with the values separated by a new line, or <c>null</c> if the line has no end quote.
/// </returns>
private string GetValuesMultilines(string[] lines, ref int index, string value)
{
value = value.TrimStart();
char quoteChar = value[0]; // Double or single-quoted
value = value.Substring(1);
int initialLine = index + 1;
int len = lines.Length;
while(++index < len)
{
var line = lines[index];
line = ExpandEnvironmentVariables(line, currentLine: index + 1);
var trimmedLine = line.TrimEnd();
if (trimmedLine.EndsWith(quoteChar))
{
var lineWithoutQuote = line.Substring(0, trimmedLine.Length - 1);
value = $"{value}\n{lineWithoutQuote}";
return value;
}
value = $"{value}\n{line}";
}

ValidationResult.Add(errorMsg: FormatParserExceptionMessage(
quoteChar == DoubleQuote ? LineHasNoEndDoubleQuoteMessage : LineHasNoEndSingleQuoteMessage,
lineNumber: initialLine,
column: 1,
envFileName: FileName
));
return null;
}
}
}
10 changes: 9 additions & 1 deletion src/Parser/EnvParser.cs
Expand Up @@ -10,6 +10,8 @@ namespace DotEnv.Core
public partial class EnvParser : IEnvParser
{
private const string ExportPrefix = "export ";
private const char DoubleQuote = '"';
private const char SingleQuote = '\'';

/// <summary>
/// The maximum number of substrings to be returned by the Split method.
Expand Down Expand Up @@ -62,7 +64,7 @@ public IEnvironmentVariablesProvider Parse(string dataSource, out EnvValidationR

var newLines = new[] { "\r\n", "\n", "\r" };
var lines = dataSource.Split(newLines, StringSplitOptions.None);
for (int i = 0, len = lines.Length; i != len; ++i)
for (int i = 0, len = lines.Length; i < len; ++i)
{
var line = lines[i];
int currentLine = i + 1;
Expand Down Expand Up @@ -96,6 +98,12 @@ public IEnvironmentVariablesProvider Parse(string dataSource, out EnvValidationR
value = TrimValue(value);
if(IsQuoted(value))
value = RemoveQuotes(value);
else if (IsMultiline(value))
{
value = GetValuesMultilines(lines, ref i, value);
if (value is null)
continue;
}
value = string.IsNullOrEmpty(value) ? " " : value;

var retrievedValue = EnvVarsProvider[key];
Expand Down
1 change: 1 addition & 0 deletions tests/DotEnv.Core.Tests.csproj
Expand Up @@ -26,6 +26,7 @@
<ContentWithTargetPath Include="Loader\env_files\.env.absolute" CopyToOutputDirectory="PreserveNewest" TargetPath=".env.absolute" />
<ContentWithTargetPath Include="Loader\env_files\.env.relative" CopyToOutputDirectory="PreserveNewest" TargetPath="dotenv\files\.env.relative" />
<ContentWithTargetPath Include="Loader\env_files\.env.only.currentdirectory" CopyToOutputDirectory="PreserveNewest" TargetPath=".env.only.currentdirectory" />
<ContentWithTargetPath Include="Parser\.env.multi-lines" CopyToOutputDirectory="PreserveNewest" TargetPath=".env.multi-lines" />
</ItemGroup>

</Project>
25 changes: 24 additions & 1 deletion tests/Loader/EnvLoaderTests.cs
Expand Up @@ -241,6 +241,7 @@ public void Load_WhenAnErrorIsFound_ShouldStoreErrorMessageInCollection()
.AddEnvFile(".env.validation.result2")
.AddEnvFile(".env.validation.result3")
.AddEnvFile(".env.validation.result4")
.AddEnvFile(".env.validation.result5")
.AddEnvFiles(
".env.not.found3",
".env.not.found4",
Expand All @@ -251,7 +252,7 @@ public void Load_WhenAnErrorIsFound_ShouldStoreErrorMessageInCollection()

msg = result.ErrorMessages;
Assert.AreEqual(expected: true, actual: result.HasError());
Assert.AreEqual(expected: 16, actual: result.Count);
Assert.AreEqual(expected: 19, actual: result.Count);

var fileName = $"{basePath}.env.validation.result1";
StringAssert.Contains(msg, FormatParserExceptionMessage(
Expand Down Expand Up @@ -342,6 +343,28 @@ public void Load_WhenAnErrorIsFound_ShouldStoreErrorMessageInCollection()
envFileName: fileName
));

fileName = $"{basePath}.env.validation.result5";
StringAssert.Contains(msg, FormatParserExceptionMessage(
LineHasNoEndDoubleQuoteMessage,
lineNumber: 1,
column: 1,
envFileName: fileName
));
StringAssert.Contains(msg, FormatParserExceptionMessage(
VariableNotSetMessage,
actualValue: "VARIABLE_NOT_FOUND",
lineNumber: 2,
column: 12,
envFileName: fileName
));
StringAssert.Contains(msg, FormatParserExceptionMessage(
VariableNotSetMessage,
actualValue: "VARIABLE_NOT_FOUND_2",
lineNumber: 3,
column: 16,
envFileName: fileName
));

StringAssert.Contains(msg, string.Format(FileNotFoundMessage, $"{basePath}.env.not.found3"));
StringAssert.Contains(msg, string.Format(FileNotFoundMessage, $"{basePath}.env.not.found4"));
StringAssert.Contains(msg, string.Format(FileNotFoundMessage, $"{basePath}.env.not.found5"));
Expand Down
3 changes: 3 additions & 0 deletions tests/Loader/env_files/validation/.env.validation.result5
@@ -0,0 +1,3 @@
MULTI_UNENDED="THIS
LINE HAS ${VARIABLE_NOT_FOUND}
NO END QUOTE ${VARIABLE_NOT_FOUND_2}
43 changes: 43 additions & 0 deletions tests/Parser/.env.multi-lines
@@ -0,0 +1,43 @@
MULTI_LINE=line

#
# Double-Quoted
#
MULTI_DOUBLE_QUOTED_1="first ${MULTI_LINE} #a
second ${MULTI_LINE} #b
third ${MULTI_LINE} #c
four ${MULTI_LINE} #d"

MULTI_DOUBLE_QUOTED_2="
first ${MULTI_LINE}
second ${MULTI_LINE}
third ${MULTI_LINE}
"

MULTI_DOUBLE_QUOTED_3="first line
"
MULTI_DOUBLE_QUOTED_4="
"
MULTI_DOUBLE_QUOTED_5="
"

#
# Single-Quoted
#
MULTI_SINGLE_QUOTED_1='first ${MULTI_LINE} #a
second ${MULTI_LINE} #b
third ${MULTI_LINE} #c
four ${MULTI_LINE} #d'

MULTI_SINGLE_QUOTED_2='
first ${MULTI_LINE}
second ${MULTI_LINE}
third ${MULTI_LINE}
'

MULTI_SINGLE_QUOTED_3='first line
'
MULTI_SINGLE_QUOTED_4='
'
MULTI_SINGLE_QUOTED_5='
'
91 changes: 91 additions & 0 deletions tests/Parser/EnvParserTests.Multilines.cs
@@ -0,0 +1,91 @@
namespace DotEnv.Core.Tests.Parser;

public partial class EnvParserTests
{
[TestMethod]
public void Parse_WhenValuesAreOnMultiLines_ShouldGetValuesSeparatedByNewLine()
{
var parser = new EnvParser().DisableTrimValues();

parser.Parse(File.ReadAllText(".env.multi-lines"));

Assert.AreEqual(
expected: "first line\nsecond line #b\nthird line #c\nfour line #d",
actual: GetEnvironmentVariable("MULTI_DOUBLE_QUOTED_1")
);
Assert.AreEqual(
expected: "\nfirst line\nsecond line\nthird line\n",
actual: GetEnvironmentVariable("MULTI_DOUBLE_QUOTED_2")
);
Assert.AreEqual(expected: "first line\n", actual: GetEnvironmentVariable("MULTI_DOUBLE_QUOTED_3"));
Assert.AreEqual(expected: "\n", actual: GetEnvironmentVariable("MULTI_DOUBLE_QUOTED_4"));
Assert.AreEqual(expected: " \n", actual: GetEnvironmentVariable("MULTI_DOUBLE_QUOTED_5"));
Assert.AreEqual(
expected: "first line\nsecond line #b\nthird line #c\nfour line #d",
actual: GetEnvironmentVariable("MULTI_SINGLE_QUOTED_1")
);
Assert.AreEqual(
expected: "\nfirst line\nsecond line\nthird line\n",
actual: GetEnvironmentVariable("MULTI_SINGLE_QUOTED_2")
);
Assert.AreEqual(expected: "first line\n", actual: GetEnvironmentVariable("MULTI_SINGLE_QUOTED_3"));
Assert.AreEqual(expected: "\n", actual: GetEnvironmentVariable("MULTI_SINGLE_QUOTED_4"));
Assert.AreEqual(expected: " \n", actual: GetEnvironmentVariable("MULTI_SINGLE_QUOTED_5"));
}

[TestMethod]
[DataRow(@"
MULTI_UNENDED='THIS
LINE HAS
NO END QUOTE
")]
[DataRow(@"MULTI_UNENDED='
THIS
LINE HAS
NO END QUOTE
")]
[DataRow(@"
MULTI_UNENDED='
THIS
LINE HAS
NO END QUOTE""
")]
public void Parse_WhenLineHasNoEndSingleQuote_ShouldThrowParserException(string input)
{
var parser = new EnvParser();

void action() => parser.Parse(input);

var ex = Assert.ThrowsException<ParserException>(action);
StringAssert.Contains(ex.Message, LineHasNoEndSingleQuoteMessage);
Assert.IsNull(GetEnvironmentVariable("MULTI_UNENDED"));
}

[TestMethod]
[DataRow(@"
MULTI_UNENDED=""THIS
LINE HAS
NO END QUOTE
")]
[DataRow(@"MULTI_UNENDED=""
THIS
LINE HAS
NO END QUOTE
")]
[DataRow(@"
MULTI_UNENDED=""
THIS
LINE HAS
NO END QUOTE'
")]
public void Parse_WhenLineHasNoEndDoubleQuote_ShouldThrowParserException(string input)
{
var parser = new EnvParser();

void action() => parser.Parse(input);

var ex = Assert.ThrowsException<ParserException>(action);
StringAssert.Contains(ex.Message, LineHasNoEndDoubleQuoteMessage);
Assert.IsNull(GetEnvironmentVariable("MULTI_UNENDED"));
}
}

0 comments on commit 8e9e285

Please sign in to comment.