Repository: dotenv-linter/dotenv-linter
Pull request #170 (2020-05-01 22:47:32)

EndingBlankLines checker
I have added the required check.
I check only the last one character and it seems that it is enough (however, it is easy to add more sequences of characters).

#### ✔ Checklist:
- [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);
- [x] Docs have been added / updated (for bug fixes / features).

codecov-io: # [Codecov](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170?src=pr&el=h1) Report
> Merging [#170](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170?src=pr&el=desc) into [master](https://codecov.io/gh/dotenv-linter/dotenv-linter/commit/debc42d4667dcb7d1cbd3e14deab8aae89a7aaf0&el=desc) will **decrease** coverage by `0.18%`.
> The diff coverage is `97.72%`.

[![Impacted file tree graph](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170/graphs/tree.svg?width=650&height=150&src=pr&token=wQybePfHI1)](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #170      +/-   ##
==========================================
- Coverage   97.86%   97.68%   -0.19%     
==========================================
  Files          11       13       +2     
  Lines         844      951     +107     
==========================================
+ Hits          826      929     +103     
- Misses         18       22       +4     
```


| [Impacted Files](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/file\_checks/ending\_blank\_line.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170/diff?src=pr&el=tree#diff-c3JjL2ZpbGVfY2hlY2tzL2VuZGluZ19ibGFua19saW5lLnJz) | `92.30% <92.30%> (ø)` | |
| [src/common.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170/diff?src=pr&el=tree#diff-c3JjL2NvbW1vbi5ycw==) | `96.12% <100.00%> (+<0.01%)` | :arrow_up: |
| [src/file\_checks.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170/diff?src=pr&el=tree#diff-c3JjL2ZpbGVfY2hlY2tzLnJz) | `100.00% <100.00%> (ø)` | |
| [src/lib.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170/diff?src=pr&el=tree#diff-c3JjL2xpYi5ycw==) | `93.05% <100.00%> (+0.86%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170?src=pr&el=footer). Last update [debc42d...efb422b](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/170?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

mgrachev: @evgeniy-r Thank you for your contribution! 🚀

I need more time to review your PR.
evgeniy-r: I have examined the issue one more time, and now I think that can solve this with less number of changes.
I think that we can just check that the last element of the `lines` collection in `checks::run` is blank.
I can update PR, if it is a good idea.
mgrachev: @evgeniy-r If you can do it, then please do.
evgeniy-r: It is ready, take a look, please.
evgeniy-r: I have rebase it on the updated master branch.
evgeniy-r: > Why have you done these changes?
> tests/cli_common.rs → tests/cli_common/mod.rs

Because `cli_common.rs` itself is not a test, it is a shared module. This is not a mandatory change, but it will be more clear.

https://doc.rust-lang.org/book/ch11-03-test-organization.html#submodules-in-integration-tests



Patch:
diff --git a/.gitignore b/.gitignore
index f2e972d..5d353e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,6 @@
 
 # These are backup files generated by rustfmt
 **/*.rs.bk
+
+# IDEs
+/.idea/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 956c434..61d1fd4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ # Changelog
 
 ## [Unreleased]
 ### 🚀 Added
+- Add check: EndingBlankLine [#170](https://github.com/dotenv-linter/dotenv-linter/pull/170) ([@evgeniy-r](https://github.com/evgeniy-r))
 - Add check: Quote characters [#174](https://github.com/dotenv-linter/dotenv-linter/pull/174) ([@sourabhmarathe](https://github.com/sourabhmarathe))
 - Github Actions: Add caching in the CI workflow [#163](https://github.com/dotenv-linter/dotenv-linter/pull/163) ([@evgeniy-r](https://github.com/evgeniy-r))
 - Add GitHub Workflow for AUR publishing [#161](https://github.com/dotenv-linter/dotenv-linter/pull/161) ([@mstruebing](https://github.com/mstruebing))
diff --git a/README.md b/README.md
index 89507b1..b125098 100644
--- a/README.md
+++ b/README.md
@@ -28,11 +28,12 @@
 It checks `.env` files for problems that may cause the application to malfunction:
 <p>
 &nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#duplicated-key">Duplicated Key</a><br />
+&nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#ending-blank-line-will-be-available-in-v200">Ending Blank Line</a> (will be available in <a href="https://github.com/dotenv-linter/dotenv-linter/issues/172">v2.0.0</a>)<br />
 &nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#incorrect-delimiter">Incorrect delimiter</a><br />
 &nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#key-without-value">Key without value</a><br />
 &nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#leading-character">Leading character</a><br />
 &nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#lowercase-key">Lowercase key</a><br />
-&nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#quote-character-will-be-available-in-v200">Quote character</a> (Will be available in v2.0.0)<br />
+&nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#quote-character-will-be-available-in-v200">Quote character</a> (will be available in <a href="https://github.com/dotenv-linter/dotenv-linter/issues/172">v2.0.0</a>)<br />
 &nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#space-character">Space character</a><br />
 &nbsp;&nbsp;&nbsp;&nbsp;✅<a href="#unordered-Key">Unordered Key</a><br />
 </p>
@@ -184,6 +185,21 @@ ### Duplicated Key
 BAR=FOO
 ```
 
+### Ending Blank Line (will be available in [v2.0.0](https://github.com/dotenv-linter/dotenv-linter/issues/172))
+
+Detects if a file doesn't have a blank line at the end.
+
+```env
+❌Wrong
+FOO=BAR
+```
+
+```env
+✅Correct
+FOO=BAR
+
+```
+
 ### Incorrect delimiter
 
 Detects if a key does not use an underscore to separate words:
@@ -248,7 +264,7 @@ ### Lowercase key
 FOO_BAR=FOOBAR
 ```
 
-### Quote character (Will be available in v2.0.0)
+### Quote character (will be available in [v2.0.0](https://github.com/dotenv-linter/dotenv-linter/issues/172))
 
 Detects if a value is wrapped in quotes:
 
diff --git a/src/checks.rs b/src/checks.rs
index fc7ccc1..f247c59 100644
--- a/src/checks.rs
+++ b/src/checks.rs
@@ -1,6 +1,7 @@
 use crate::common::*;
 
 mod duplicated_key;
+mod ending_blank_line;
 mod incorrect_delimiter;
 mod key_without_value;
 mod leading_character;
@@ -32,16 +33,23 @@ pub fn run(lines: Vec<LineEntry>) -> Vec<Warning> {
     let mut checks = checklist();
     let mut warnings: Vec<Warning> = Vec::new();
 
-    for line in lines {
-        if line.is_empty_or_comment() {
-            continue;
-        }
+    if let Some((last_line, rest_lines)) = lines.split_last() {
+        for line in rest_lines {
+            if line.is_empty_or_comment() {
+                continue;
+            }
 
-        for ch in &mut checks {
-            if let Some(warning) = ch.run(&line) {
-                warnings.push(warning);
+            for ch in &mut checks {
+                if let Some(warning) = ch.run(line) {
+                    warnings.push(warning);
+                }
             }
         }
+
+        let mut ending_line_checker = ending_blank_line::EndingBlankLineChecker::default();
+        if let Some(warning) = ending_line_checker.run(last_line) {
+            warnings.push(warning);
+        }
     }
 
     warnings
@@ -52,6 +60,22 @@ mod tests {
     use super::*;
     use std::path::PathBuf;
 
+    fn blank_entry(number: usize) -> LineEntry {
+        LineEntry {
+            number,
+            file_path: PathBuf::from(".env"),
+            raw_string: String::from("\n"),
+        }
+    }
+
+    fn line_entry(number: usize, str: &str) -> LineEntry {
+        LineEntry {
+            number,
+            file_path: PathBuf::from(".env"),
+            raw_string: String::from(str),
+        }
+    }
+
     #[test]
     fn run_with_empty_vec_test() {
         let empty: Vec<LineEntry> = Vec::new();
@@ -62,13 +86,7 @@ fn run_with_empty_vec_test() {
 
     #[test]
     fn run_with_empty_line_test() {
-        let line = LineEntry {
-            number: 1,
-            file_path: PathBuf::from(".env"),
-            raw_string: String::from(""),
-        };
-
-        let lines: Vec<LineEntry> = vec![line];
+        let lines: Vec<LineEntry> = vec![line_entry(1, ""), blank_entry(2)];
         let expected: Vec<Warning> = Vec::new();
 
         assert_eq!(expected, run(lines));
@@ -76,13 +94,7 @@ fn run_with_empty_line_test() {
 
     #[test]
     fn run_with_comment_line_test() {
-        let line = LineEntry {
-            number: 1,
-            file_path: PathBuf::from(".env"),
-            raw_string: String::from("# Comment"),
-        };
-
-        let lines: Vec<LineEntry> = vec![line];
+        let lines: Vec<LineEntry> = vec![line_entry(1, "# Comment"), blank_entry(2)];
         let expected: Vec<Warning> = Vec::new();
 
         assert_eq!(expected, run(lines));
@@ -90,13 +102,7 @@ fn run_with_comment_line_test() {
 
     #[test]
     fn run_with_valid_line_test() {
-        let line = LineEntry {
-            number: 1,
-            file_path: PathBuf::from(".env"),
-            raw_string: String::from("FOO=BAR"),
-        };
-
-        let lines: Vec<LineEntry> = vec![line];
+        let lines: Vec<LineEntry> = vec![line_entry(1, "FOO=BAR"), blank_entry(2)];
         let expected: Vec<Warning> = Vec::new();
 
         assert_eq!(expected, run(lines));
@@ -104,17 +110,26 @@ fn run_with_valid_line_test() {
 
     #[test]
     fn run_with_invalid_line_test() {
-        let line = LineEntry {
-            number: 1,
-            file_path: PathBuf::from(".env"),
-            raw_string: String::from("FOO"),
-        };
+        let line = line_entry(1, "FOO");
         let warning = Warning::new(
             line.clone(),
             String::from(
                 "KeyWithoutValue: The FOO key should be with a value or have an equal sign",
             ),
         );
+        let lines: Vec<LineEntry> = vec![line, blank_entry(2)];
+        let expected: Vec<Warning> = vec![warning];
+
+        assert_eq!(expected, run(lines));
+    }
+
+    #[test]
+    fn run_without_blank_line_test() {
+        let line = line_entry(1, "FOO=BAR");
+        let warning = Warning::new(
+            line.clone(),
+            String::from("EndingBlankLine: No blank line at the end of the file"),
+        );
         let lines: Vec<LineEntry> = vec![line];
         let expected: Vec<Warning> = vec![warning];
 
diff --git a/src/checks/ending_blank_line.rs b/src/checks/ending_blank_line.rs
new file mode 100644
index 0000000..520d241
--- /dev/null
+++ b/src/checks/ending_blank_line.rs
@@ -0,0 +1,79 @@
+use crate::checks::Check;
+use crate::common::*;
+
+pub(crate) struct EndingBlankLineChecker {
+    name: &'static str,
+    template: &'static str,
+}
+
+impl Default for EndingBlankLineChecker {
+    fn default() -> Self {
+        Self {
+            name: "EndingBlankLine",
+            template: "No blank line at the end of the file",
+        }
+    }
+}
+
+impl EndingBlankLineChecker {
+    fn message(&self) -> String {
+        format!("{}: {}", self.name, self.template)
+    }
+}
+
+impl Check for EndingBlankLineChecker {
+    fn run(&mut self, line: &LineEntry) -> Option<Warning> {
+        if line.raw_string.ends_with('\n') {
+            None
+        } else {
+            Some(Warning::new(line.clone(), self.message()))
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use std::path::PathBuf;
+
+    #[test]
+    fn blank_line() {
+        let mut checker = EndingBlankLineChecker::default();
+        let line = LineEntry {
+            number: 1,
+            file_path: PathBuf::from(".env"),
+            raw_string: String::from("\n"),
+        };
+
+        assert_eq!(None, checker.run(&line));
+    }
+
+    #[test]
+    fn blank_line_rn() {
+        let mut checker = EndingBlankLineChecker::default();
+        let line = LineEntry {
+            number: 1,
+            file_path: PathBuf::from(".env"),
+            raw_string: String::from("\r\n"),
+        };
+
+        assert_eq!(None, checker.run(&line));
+    }
+
+    #[test]
+    fn no_blank_line() {
+        let mut checker = EndingBlankLineChecker::default();
+        let line = LineEntry {
+            number: 1,
+            file_path: PathBuf::from(".env"),
+            raw_string: String::from("a"),
+        };
+        let expected = Some(Warning::new(
+            line.clone(),
+            String::from("EndingBlankLine: No blank line at the end of the file"),
+        ));
+
+        assert_eq!(expected, checker.run(&line));
+    }
+}
diff --git a/src/common.rs b/src/common.rs
index 377d605..cf79bd6 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -63,9 +63,15 @@ pub struct LineEntry {
 
 impl LineEntry {
     pub fn is_empty_or_comment(&self) -> bool {
-        let trimmed_string = self.raw_string.trim();
+        self.is_empty() || self.is_comment()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.trimmed_string().is_empty()
+    }
 
-        trimmed_string.is_empty() || trimmed_string.starts_with('#')
+    pub fn is_comment(&self) -> bool {
+        self.trimmed_string().starts_with('#')
     }
 
     pub fn get_key(&self) -> Option<String> {
@@ -73,8 +79,8 @@ pub fn get_key(&self) -> Option<String> {
             return None;
         }
 
-        match self.raw_string.find('=') {
-            Some(index) => Some(self.raw_string[..index].to_owned()),
+        match self.trimmed_string().find('=') {
+            Some(index) => Some(self.trimmed_string()[..index].to_owned()),
             None => None,
         }
     }
@@ -89,6 +95,10 @@ pub fn get_value(&self) -> Option<String> {
             None => None,
         }
     }
+
+    pub fn trimmed_string(&self) -> &str {
+        self.raw_string.trim()
+    }
 }
 
 #[cfg(test)]
@@ -178,6 +188,8 @@ fn run_with_empty_line_test() {
                     raw_string: String::from(""),
                 };
 
+                assert_eq!(input.is_empty(), true);
+                assert_eq!(input.is_comment(), false);
                 assert_eq!(input.is_empty_or_comment(), true);
             }
 
@@ -189,6 +201,8 @@ fn run_with_comment_line_test() {
                     raw_string: String::from("# Comment"),
                 };
 
+                assert_eq!(input.is_empty(), false);
+                assert_eq!(input.is_comment(), true);
                 assert_eq!(input.is_empty_or_comment(), true);
             }
 
@@ -200,6 +214,8 @@ fn run_with_not_comment_or_empty_line_test() {
                     raw_string: String::from("NotComment"),
                 };
 
+                assert_eq!(input.is_empty(), false);
+                assert_eq!(input.is_comment(), false);
                 assert_eq!(input.is_empty_or_comment(), false);
             }
         }
@@ -319,5 +335,42 @@ fn missing_value_and_equal_sign_test() {
                 assert_eq!(expected, input.get_value());
             }
         }
+
+        mod trimmed_string {
+            use super::*;
+
+            #[test]
+            fn line_without_blank_chars_test() {
+                let entry = LineEntry {
+                    number: 1,
+                    file_path: PathBuf::from(".env"),
+                    raw_string: String::from("FOO=BAR"),
+                };
+
+                assert_eq!("FOO=BAR", entry.trimmed_string());
+            }
+
+            #[test]
+            fn line_with_spaces_test() {
+                let entry = LineEntry {
+                    number: 1,
+                    file_path: PathBuf::from(".env"),
+                    raw_string: String::from("   FOO=BAR  "),
+                };
+
+                assert_eq!("FOO=BAR", entry.trimmed_string());
+            }
+
+            #[test]
+            fn line_with_tab_test() {
+                let entry = LineEntry {
+                    number: 1,
+                    file_path: PathBuf::from(".env"),
+                    raw_string: String::from("FOO=BAR\t"),
+                };
+
+                assert_eq!("FOO=BAR", entry.trimmed_string());
+            }
+        }
     }
 }
diff --git a/src/lib.rs b/src/lib.rs
index 734174f..78df02f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,9 +4,8 @@
 use std::env;
 use std::error::Error;
 use std::ffi::OsStr;
-use std::fs;
-use std::fs::File;
-use std::io::{BufRead, BufReader};
+use std::fs::{self, File};
+use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom};
 use std::path::PathBuf;
 
 mod checks;
@@ -38,6 +37,43 @@ fn get_args(current_dir: &OsStr) -> clap::ArgMatches {
         .get_matches()
 }
 
+fn run_checks(fe: &FileEntry) -> io::Result<Vec<Warning>> {
+    let mut f = File::open(&fe.path)?;
+
+    let reader = BufReader::new(&f);
+
+    // TODO: Initialize a vector with a capacity equal to the number of lines
+    let mut lines: Vec<LineEntry> = Vec::new();
+
+    let mut number = 0;
+    for (index, line) in reader.lines().enumerate() {
+        number = index + 1;
+        let raw_string = line?;
+
+        lines.push(LineEntry {
+            number,
+            file_path: fe.path.clone(),
+            raw_string,
+        })
+    }
+
+    let mut last_line = String::new();
+    let ending_seq_length = 1;
+    if f.seek(SeekFrom::End(-ending_seq_length)).is_ok()
+        && f.read_to_string(&mut last_line)? == ending_seq_length as usize
+    {
+        // We add a special LineEntry with the final character in the file
+        // for the "Ending Blank Line" check.
+        lines.push(LineEntry {
+            number: number + 1,
+            file_path: fe.path.clone(),
+            raw_string: last_line,
+        })
+    }
+
+    Ok(checks::run(lines))
+}
+
 #[allow(clippy::redundant_closure)]
 pub fn run() -> Result<Vec<Warning>, Box<dyn Error>> {
     let current_dir = match env::current_dir() {
@@ -107,23 +143,7 @@ pub fn run() -> Result<Vec<Warning>, Box<dyn Error>> {
 
     let mut warnings: Vec<Warning> = Vec::new();
     for file in new_files {
-        let f = File::open(&file.path)?;
-        let reader = BufReader::new(f);
-
-        // TODO: Initialize a vector with a capacity equal to the number of lines
-        let mut lines: Vec<LineEntry> = Vec::new();
-        for (index, line) in reader.lines().enumerate() {
-            let number = index + 1;
-            let raw_string = line?;
-
-            lines.push(LineEntry {
-                number,
-                file_path: file.path.clone(),
-                raw_string,
-            })
-        }
-
-        let result = checks::run(lines);
+        let result = run_checks(&file)?;
         warnings.extend(result);
     }
 


Test patch:
diff --git a/tests/cli_basic.rs b/tests/cli_basic.rs
index 7e89282..f1715e5 100644
--- a/tests/cli_basic.rs
+++ b/tests/cli_basic.rs
@@ -6,14 +6,14 @@
 #[test]
 fn exits_with_0_on_no_errors() {
     let test_dir = TestDir::new();
-    test_dir.create_testfile(".env", "FOO=bar");
+    test_dir.create_testfile(".env", "FOO=bar\n");
     test_dir.test_command_success();
 }
 
 #[test]
 fn checks_current_dir() {
     let testdir = TestDir::new();
-    let testfile = testdir.create_testfile(".env", "FOO");
+    let testfile = testdir.create_testfile(".env", "FOO\n");
 
     testdir.test_command_fail(format!(
         "{}:1 KeyWithoutValue: The FOO key should be with a value or have an equal sign\n",
@@ -24,7 +24,7 @@ fn checks_current_dir() {
 #[test]
 fn checks_current_dir_with_dot_arg() {
     let testdir = TestDir::new();
-    let testfile = testdir.create_testfile("test.env", "foo=");
+    let testfile = testdir.create_testfile("test.env", "foo=\n");
 
     let args = &["."];
     let expected_output = format!(
diff --git a/tests/cli_common.rs b/tests/cli_common/mod.rs
similarity index 98%
rename from tests/cli_common.rs
rename to tests/cli_common/mod.rs
index 44b370c..0c03f43 100644
--- a/tests/cli_common.rs
+++ b/tests/cli_common/mod.rs
@@ -123,7 +123,7 @@ impl TestFile {
     pub fn new(test_dir: &TempDir, name: &str, contents: &str) -> Self {
         let file_path = test_dir.path().join(name);
         let mut file = File::create(&file_path).expect("create testfile");
-        writeln!(file, "{}", contents).expect("write to file");
+        file.write_all(contents.as_bytes()).expect("write to file");
 
         Self { file_path }
     }
diff --git a/tests/cli_exclude.rs b/tests/cli_exclude.rs
index bfdff04..3e87662 100644
--- a/tests/cli_exclude.rs
+++ b/tests/cli_exclude.rs
@@ -6,15 +6,15 @@
 #[test]
 fn exclude_one_file() {
     let test_dir = TestDir::new();
-    let testfile = test_dir.create_testfile(".env", " FOO=");
+    let testfile = test_dir.create_testfile(".env", " FOO=\n");
     test_dir.test_command_success_with_args(&["--exclude", testfile.as_str()]);
 }
 
 #[test]
 fn exclude_two_files() {
     let test_dir = TestDir::new();
-    let testfile_1 = test_dir.create_testfile(".env", " FOO=");
-    let testfile_2 = test_dir.create_testfile(".loacl.env", " BAR=");
+    let testfile_1 = test_dir.create_testfile(".env", " FOO=\n");
+    let testfile_2 = test_dir.create_testfile(".loacl.env", " BAR=\n");
 
     test_dir.test_command_success_with_args(&[
         "-e",
@@ -27,8 +27,8 @@ fn exclude_two_files() {
 #[test]
 fn exclude_one_file_check_one_file() {
     let test_dir = TestDir::new();
-    let testfile_to_check = test_dir.create_testfile(".env", " FOO=");
-    let testfile_to_exclude = test_dir.create_testfile(".exclude-me.env", " BAR=");
+    let testfile_to_check = test_dir.create_testfile(".env", " FOO=\n");
+    let testfile_to_exclude = test_dir.create_testfile(".exclude-me.env", " BAR=\n");
 
     let args = &["--exclude", testfile_to_exclude.as_str()];
     let expected_output = format!(
diff --git a/tests/cli_specific.rs b/tests/cli_specific.rs
index 407c3db..2afa3be 100644
--- a/tests/cli_specific.rs
+++ b/tests/cli_specific.rs
@@ -6,10 +6,10 @@
 #[test]
 fn checks_one_specific_path() {
     let testdir = TestDir::new();
-    testdir.create_testfile(".env", "foo=");
+    testdir.create_testfile(".env", "foo=\n");
 
     let subdir = testdir.subdir();
-    let testfile_2 = subdir.create_testfile(".env.test", "1FOO=");
+    let testfile_2 = subdir.create_testfile(".env.test", "1FOO=\n");
 
     let args = &[subdir.as_str()];
     let expected_output = format!(
@@ -24,13 +24,13 @@ fn checks_one_specific_path() {
 #[test]
 fn checks_two_specific_paths() {
     let testdir = TestDir::new();
-    testdir.create_testfile(".env", "foo=");
+    testdir.create_testfile(".env", "foo=\n");
 
     let subdir_1 = testdir.subdir();
-    let testfile_2 = subdir_1.create_testfile(".env", " FOO=");
+    let testfile_2 = subdir_1.create_testfile(".env", " FOO=\n");
 
     let subdir_2 = subdir_1.subdir();
-    let testfile_3 = subdir_2.create_testfile(".env", " FOO=");
+    let testfile_3 = subdir_2.create_testfile(".env", " FOO=\n");
 
     let args = &[subdir_1.as_str(), subdir_2.as_str()];
     let expected_output = format!(
@@ -47,8 +47,8 @@ fn checks_two_specific_paths() {
 #[test]
 fn checks_one_specific_file() {
     let test_dir = TestDir::new();
-    test_dir.create_testfile(".env", "foo=");
-    let testfile_2 = test_dir.create_testfile("test-env-file", "FOO =");
+    test_dir.create_testfile(".env", "foo=\n");
+    let testfile_2 = test_dir.create_testfile("test-env-file", "FOO =\n");
 
     let args = &[testfile_2.as_str()];
     let expected_output = format!(
@@ -63,10 +63,10 @@ fn checks_one_specific_file() {
 fn checks_two_specific_files() {
     let testdir = TestDir::new();
     testdir.create_testfile(".env", "foo=");
-    let testfile_2 = testdir.create_testfile("test-env-file", "FOO =");
+    let testfile_2 = testdir.create_testfile("test-env-file", "FOO =\n");
 
     let subdir = testdir.subdir();
-    let testfile_3 = subdir.create_testfile("another_test_file", "FOO=BAR\nFOO=BAR");
+    let testfile_3 = subdir.create_testfile("another_test_file", "FOO=BAR\nFOO=BAR\n");
 
     let args = &[testfile_2.as_str(), testfile_3.as_str()];
     let expected_output = format!(
@@ -83,10 +83,10 @@ fn checks_two_specific_files() {
 fn checks_one_specific_file_and_one_path() {
     let testdir = TestDir::new();
     testdir.create_testfile(".env", "foo=");
-    let testfile_2 = testdir.create_testfile("test-env-file", "FOO=BAR\nBAR=FOO");
+    let testfile_2 = testdir.create_testfile("test-env-file", "FOO=BAR\nBAR=FOO\n");
 
     let subdir = testdir.subdir();
-    let testfile_3 = subdir.create_testfile("test.env", "FOO=BAR\nFOO=BAR");
+    let testfile_3 = subdir.create_testfile("test.env", "FOO=BAR\nFOO=BAR\n");
 
     let args = &[testfile_2.as_str(), subdir.as_str()];
     let expected_output = format!(
@@ -103,7 +103,7 @@ fn checks_one_specific_file_and_one_path() {
 fn checks_one_specific_file_twice() {
     let test_dir = TestDir::new();
     test_dir.create_testfile(".env", "foo=");
-    let testfile_2 = test_dir.create_testfile("test-env-file", "1FOO=");
+    let testfile_2 = test_dir.create_testfile("test-env-file", "1FOO=\n");
 
     let args = &[testfile_2.as_str(), testfile_2.as_str()];
     let expected_output = format!(
diff --git a/tests/ending_blank_line.rs b/tests/ending_blank_line.rs
new file mode 100644
index 0000000..6fbc5c9
--- /dev/null
+++ b/tests/ending_blank_line.rs
@@ -0,0 +1,45 @@
+#[allow(dead_code)]
+mod cli_common;
+
+use cli_common::TestDir;
+
+#[test]
+fn correct_files() {
+    let contents = vec![
+        "A=B\nF=BAR\nFOO=BAR\n",
+        "A=B\r\nF=BAR\r\nFOO=BAR\r\n",
+        "# comment\nABC=DEF\n",
+    ];
+
+    for content in contents {
+        let testdir = TestDir::new();
+        let testfile = testdir.create_testfile(".env", content);
+        let args = &[testfile.as_str()];
+
+        testdir.test_command_success_with_args(args);
+    }
+}
+
+#[test]
+fn incorrect_files() {
+    let contents = vec![
+        "ABC=DEF\nD=BAR\nFOO=BAR",
+        "C=D\r\nK=L\r\nX=Y",
+        "A=B",
+        "# Comment 1\n# Comment 2\n# Comment 3",
+    ];
+    let expected_line_numbers = vec![4, 4, 2, 4];
+
+    for (i, content) in contents.iter().enumerate() {
+        let testdir = TestDir::new();
+        let testfile = testdir.create_testfile(".env", content);
+        let args = &[testfile.as_str()];
+        let expected_output = format!(
+            "{}:{} EndingBlankLine: No blank line at the end of the file\n",
+            testfile.shortname_as_str(),
+            expected_line_numbers[i]
+        );
+
+        testdir.test_command_fail_with_args(args, expected_output);
+    }
+}