Repository: dotenv-linter/dotenv-linter (rust)

Issue:
Real multiple line problem
Hi,
But what about variables containing real newlines?
for example:

```
# .env.example
FOO=baz
MULTILINE_FOO='{
    "first": 1,
    "second": 1
  }'
BAR=baz
```
it is a real work example with `python-dotenv`
and dotenv-linter doesn't work with them:
```
dotenv-linter .env.example
Checking .env.example
.env.example:2 QuoteCharacter: The value has quote characters (', ")
.env.example:3 IncorrectDelimiter: The "first": 1, key has incorrect delimiter
.env.example:3 KeyWithoutValue: The "first": 1, key should be with a value or have an equal sign
.env.example:3 LeadingCharacter: Invalid leading character detected
.env.example:3 LowercaseKey: The "first": 1, key should be in uppercase
.env.example:3 UnorderedKey: The "first": 1, key should go before the FOO key
.env.example:4 IncorrectDelimiter: The "second": 1 key has incorrect delimiter
.env.example:4 KeyWithoutValue: The "second": 1 key should be with a value or have an equal sign
.env.example:4 LeadingCharacter: Invalid leading character detected
.env.example:4 LowercaseKey: The "second": 1 key should be in uppercase
.env.example:4 UnorderedKey: The "second": 1 key should go before the FOO key
.env.example:5 KeyWithoutValue: The }' key should be with a value or have an equal sign
.env.example:5 LeadingCharacter: Invalid leading character detected
.env.example:6 UnorderedKey: The BAR key should go before the FOO key

Found 14 problems
```



Pull request description:
feat: add support for multi-line single-quoted values
Proof of concept for real multi-line value support #442.
~~It's temporary ugly solution, but it works.~~

Currently, I have added support of real multiline single-quoted values.
This implementation allows us to perform all checks on such values, since they are transformed into one `LineEntry`.

@dotenv-linter/core  I suggest to consider this PR and, if approved, add support of multiline  double-quoted values in another pull request (if needed at all) 🤔 

Partially closes #442

<!--
Thank you for your pull request. Please provide a description above and review
the requirements below.

Bug fixes and new features should include tests.
-->

<!-- _Please make sure to review and check all of these items:_ -->

#### ✔ Checklist:
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] Commit messages have been written in [Conventional Commits](https://www.conventionalcommits.org) format;
- [x] This PR has been added to [CHANGELOG.md](https://github.com/dotenv-linter/dotenv-linter/blob/master/CHANGELOG.md) (at the top of the list);
- [x] Tests for the changes have been added (for bug fixes / features);
- [ ] Docs have been added / updated on the [dotenv-linter.github.io](https://github.com/dotenv-linter/dotenv-linter.github.io) (for bug fixes / features).

<!-- _NOTE: these things are not required to open a PR and can be done afterwards / while the PR is open._ -->


Patch:
diff --git a/.gitignore b/.gitignore
index 25e7dec..d47d159 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,8 @@
 
 # IDEs
 /.idea/
+/.vscode/
+/.history/
 
 # Environment Files
 *.env
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf0dae4..2903073 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ # Changelog
 
 ## [Unreleased]
 ### 🚀 Added
+- Support of single-quoted multiline values [#450](https://github.com/dotenv-linter/dotenv-linter/pull/450) ([@DDtKey](https://github.com/DDtKey))
 
 ### 🔧 Changed
 - Get rid of `Rc<FileEntry>` in `LineEntry` [#448](https://github.com/dotenv-linter/dotenv-linter/pull/448) ([@mgrachev](https://github.com/mgrachev))
diff --git a/src/checks/substitution_key.rs b/src/checks/substitution_key.rs
index 14b1006..8c9c81b 100644
--- a/src/checks/substitution_key.rs
+++ b/src/checks/substitution_key.rs
@@ -1,5 +1,5 @@
 use super::Check;
-use crate::common::{LineEntry, LintKind, Warning};
+use crate::common::{is_escaped, LineEntry, LintKind, Warning};
 
 pub(crate) struct SubstitutionKeyChecker<'a> {
     template: &'a str,
@@ -26,9 +26,6 @@ fn run(&mut self, line: &LineEntry) -> Option<Warning> {
             _ => return None,
         };
 
-        let is_escaped =
-            |prefix: &str| prefix.chars().rev().take_while(|ch| *ch == '\\').count() % 2 == 1;
-
         // Checks if keys used in value have both '{' '}' or neither
         while let Some(index) = value.find('$') {
             let prefix = &value[..index];
diff --git a/src/common.rs b/src/common.rs
index aec18ed..1ad6b56 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -23,6 +23,10 @@ pub fn remove_invalid_leading_chars(string: &str) -> &str {
     string.trim_start_matches(|c: char| !(c.is_alphabetic() || c == '_'))
 }
 
+pub fn is_escaped(prefix: &str) -> bool {
+    prefix.chars().rev().take_while(|ch| *ch == '\\').count() % 2 == 1
+}
+
 #[cfg(test)]
 pub(crate) mod tests {
     use super::*;
@@ -93,4 +97,19 @@ fn remove_invalid_leading_chars_test() {
         let string = "***FOO-BAR";
         assert_eq!("FOO-BAR", remove_invalid_leading_chars(string));
     }
+
+    #[test]
+    fn is_escaped_value_test() {
+        let escaped = "\\";
+        assert!(is_escaped(escaped));
+
+        let escaped = "\\\\\\";
+        assert!(is_escaped(escaped));
+
+        let non_escaped = "\\\\";
+        assert!(!is_escaped(non_escaped));
+
+        let random_string = "text without escaping";
+        assert!(!is_escaped(random_string));
+    }
 }
diff --git a/src/common/line_entry.rs b/src/common/line_entry.rs
index dac5c70..3aa7c31 100644
--- a/src/common/line_entry.rs
+++ b/src/common/line_entry.rs
@@ -1,4 +1,4 @@
-use super::comment::Comment;
+use super::{comment::Comment, is_escaped};
 
 #[derive(Clone, Debug, PartialEq)]
 pub struct LineEntry {
@@ -89,9 +89,6 @@ pub fn get_substitution_keys(&self) -> Vec<&str> {
             _ => return keys,
         };
 
-        let is_escaped =
-            |prefix: &str| prefix.chars().rev().take_while(|ch| *ch == '\\').count() % 2 == 1;
-
         if value.starts_with('\"') {
             if value.ends_with('\"') && !is_escaped(&value[..value.len() - 1]) {
                 value = &value[1..value.len() - 1]
diff --git a/src/fixes.rs b/src/fixes.rs
index 973b2d8..cca6430 100644
--- a/src/fixes.rs
+++ b/src/fixes.rs
@@ -19,7 +19,9 @@ trait Fix {
     fn fix_warnings(&self, warning_lines: &[usize], lines: &mut Vec<LineEntry>) -> Option<usize> {
         let mut count: usize = 0;
         for line_number in warning_lines {
-            let line = lines.get_mut(line_number - 1)?;
+            let line = lines
+                .iter_mut()
+                .find(|entry| entry.number == *line_number)?;
             if self.fix_line(line).is_some() {
                 count += 1;
             }
@@ -143,7 +145,7 @@ fn run_when_lines_do_not_fit_numbers_test() {
             ),
         ];
 
-        assert_eq!(0, run(&mut warnings, &mut lines, &[]));
+        assert_eq!(2, run(&mut warnings, &mut lines, &[]));
     }
 
     #[test]
diff --git a/src/fixes/substitution_key.rs b/src/fixes/substitution_key.rs
index fbf0804..3f28ef0 100644
--- a/src/fixes/substitution_key.rs
+++ b/src/fixes/substitution_key.rs
@@ -1,5 +1,5 @@
 use super::Fix;
-use crate::common::{LineEntry, LintKind};
+use crate::common::{is_escaped, LineEntry, LintKind};
 
 pub(crate) struct SubstitutionKeyFixer {}
 
@@ -21,9 +21,6 @@ fn fix_line(&self, line: &mut LineEntry) -> Option<()> {
             .map(str::trim)
             .filter(|val| !val.starts_with('\''))?;
 
-        let is_escaped =
-            |prefix: &str| prefix.chars().rev().take_while(|ch| *ch == '\\').count() % 2 == 1;
-
         let mut result = String::with_capacity(value.len());
 
         // Checks if keys used in value have both '{' '}' or neither
diff --git a/src/fixes/unordered_key.rs b/src/fixes/unordered_key.rs
index 789a07d..c15b91b 100644
--- a/src/fixes/unordered_key.rs
+++ b/src/fixes/unordered_key.rs
@@ -68,7 +68,7 @@ fn fix_warnings(&self, warning_lines: &[usize], lines: &mut Vec<LineEntry>) -> O
                 }
 
                 if line.is_empty()
-                    || lines.len() == line.number // Is this the last line?
+                    || lines.len() == i + 1 // Is this the last line?
                     || is_control_comment
                     || has_substitution_variables
                 {
@@ -115,11 +115,7 @@ fn sort_part(part: &mut [LineEntry]) {
             }
         });
 
-        slices.sort_by_cached_key(|slice| {
-            // I think, that we should modify get_key() so it will return Option<&str> instead of
-            // Option<String>.
-            slice.last()?.get_key()
-        });
+        slices.sort_by_cached_key(|slice| slice.last()?.get_key());
 
         let sorted_lines: Vec<_> = slices.into_iter().flat_map(|s| s.iter().cloned()).collect();
 
diff --git a/src/lib.rs b/src/lib.rs
index 8078bba..81392a7 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -5,7 +5,6 @@
 use crate::common::*;
 
 pub use checks::available_check_names;
-
 mod checks;
 mod common;
 mod fixes;
@@ -237,9 +236,67 @@ fn get_file_paths(
 fn get_line_entries(lines: Vec<String>) -> Vec<LineEntry> {
     let length = lines.len();
 
-    lines
+    let mut lines: Vec<LineEntry> = lines
         .into_iter()
         .enumerate()
         .map(|(index, line)| LineEntry::new(index + 1, line, length == (index + 1)))
-        .collect()
+        .collect();
+
+    reduce_multiline_entries(&mut lines);
+    lines
+}
+
+fn reduce_multiline_entries(lines: &mut Vec<LineEntry>) {
+    let length = lines.len();
+    let multiline_ranges = find_multiline_ranges(lines);
+
+    // Replace multiline value to one line-entry for checking
+    let mut offset = 1; // index offset to account deleted lines (for access by index)
+    for (start, end) in multiline_ranges {
+        let result = lines
+            .drain(start - offset..end - offset + 1) // TODO: consider `drain_filter` (after stabilization in rust std)
+            .map(|entry| entry.raw_string)
+            .reduce(|result, line| result + "\n" + &line); // TODO: `intersperse` (after stabilization in rust std)
+
+        if let Some(value) = result {
+            lines.insert(start - offset, LineEntry::new(start, value, length == end));
+        }
+
+        offset += end - start;
+    }
+}
+
+fn find_multiline_ranges(lines: &[LineEntry]) -> Vec<(usize, usize)> {
+    let mut multiline_ranges: Vec<(usize, usize)> = Vec::new();
+    let mut start_number: Option<usize> = None;
+
+    // here we find ranges of multi-line values
+    lines.iter().for_each(|entry| {
+        if let Some(start) = start_number {
+            if let Some(idx) = entry.raw_string.find('\'') {
+                if !is_escaped(&entry.raw_string[..idx]) {
+                    multiline_ranges.push((start, entry.number));
+                    start_number = None;
+                }
+            } else if entry.get_value().is_some() {
+                // if next line correct env line - then previous start-line incorrect multi-value
+                start_number = None;
+            }
+        } else {
+            let is_multiline_start = entry
+                .get_value()
+                .map(|val| val.trim())
+                .filter(|val| {
+                    val.starts_with('\'')
+                        && (!val.ends_with('\'') || is_escaped(&val[..val.len() - 1]))
+                })
+                .is_some();
+
+            if is_multiline_start {
+                start_number = Some(entry.number);
+            }
+        }
+    });
+
+    multiline_ranges
 }


Test patch:
diff --git a/tests/fixes/fixtures/complicated.env b/tests/fixes/fixtures/complicated.env
index 6611f17..c65927e 100644
--- a/tests/fixes/fixtures/complicated.env
+++ b/tests/fixes/fixtures/complicated.env
@@ -70,4 +70,16 @@ VAR_4=1234
 VAR_3=1234
 VAR_2=1234
 VAR_1=1234
-ENV24='key56'
\ No newline at end of file
+ENV24='key56'
+# dotenv-linter:on QuoteCharacter
+
+# multiline values & ordering:
+
+ZOO=BAR
+M_ENV1=   '{
+    "first": 1,
+    "second": 1
+  }'
+ABC=FOO
+M_ENV2 = 'multiline \'escaped\'
+    value'
\ No newline at end of file
diff --git a/tests/fixes/fixtures/complicated.env.golden b/tests/fixes/fixtures/complicated.env.golden
index 035f0a2..a1eaade 100644
--- a/tests/fixes/fixtures/complicated.env.golden
+++ b/tests/fixes/fixtures/complicated.env.golden
@@ -69,3 +69,15 @@ VAR_1=1234
 VAR_5=1234
 VAR_6=1234
 VAR_7=1234
+# dotenv-linter:on QuoteCharacter
+
+# multiline values & ordering:
+
+ABC=FOO
+M_ENV1='{
+    "first": 1,
+    "second": 1
+  }'
+M_ENV2='multiline \'escaped\'
+    value'
+ZOO=BAR
diff --git a/tests/fixes/fixtures/correct.env b/tests/fixes/fixtures/correct.env
index 12b70a0..d3b9e7f 100644
--- a/tests/fixes/fixtures/correct.env
+++ b/tests/fixes/fixtures/correct.env
@@ -16,3 +16,9 @@ A_ENV_12=C
 A_ENV_13=D
 A_ENV_14=123
 A_ENV_15=345
+
+# multiline value
+M_ENV1='{
+    "first": 1,
+    "second": 1
+  }'
diff --git a/tests/fixes/fixtures/correct.env.golden b/tests/fixes/fixtures/correct.env.golden
index 12b70a0..d3b9e7f 100644
--- a/tests/fixes/fixtures/correct.env.golden
+++ b/tests/fixes/fixtures/correct.env.golden
@@ -16,3 +16,9 @@ A_ENV_12=C
 A_ENV_13=D
 A_ENV_14=123
 A_ENV_15=345
+
+# multiline value
+M_ENV1='{
+    "first": 1,
+    "second": 1
+  }'
diff --git a/tests/output/check.rs b/tests/output/check.rs
index 490ddf3..c46ea1c 100644
--- a/tests/output/check.rs
+++ b/tests/output/check.rs
@@ -38,6 +38,22 @@ fn problems_multiple_files() {
     test_dir.test_command_fail(expected_output);
 }
 
+#[test]
+fn valid_multiline_value_test() {
+    let test_dir = TestDir::new();
+    test_dir.create_testfile(
+        ".env",
+        "FOO=bar\nMULTILINE_1='{\n\"first\": 1,\n\"second\": 1\n}'\nMULTILINE_2='multiline \\'escaped\\' \n value'\nZAC=baz\n",
+    );
+
+    let expected_output = r#"Checking .env
+
+No problems found
+"#;
+
+    test_dir.test_command_success(expected_output);
+}
+
 #[test]
 fn problems_first_and_last_file() {
     let test_dir = TestDir::new();