Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for multi-line values #101

Merged
merged 4 commits into from Jun 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"));
}
}