Repository: roskakori/pygount (python)

Issue:
Add file count to summary
Goal: when `--format=summary` is specified, the first two columns of the output show the file count for each language and the respective percentage.


Pull request description:
#37 Added file count to summary
Closes #37

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 8090b79..dae8ce2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,9 +1,13 @@
 # Pygount version history
 
-Version 1.2.0, 2020-03-TODO
+Version 1.2.0, 2020-03-30
 
+* Added file count to summary.
 * Changed installation to fail when attempting to install on Python earlier
   than 3.5.
+* Improved API:
+  * Changed `SourceAnalysis.state` to be a proper enum instead of a string.
+  * Added`ProjectSummary` to summarize multiple files.
 * Cleaned up project:
   * Changed continuous integration from Travis CI to Github actions in the hope
     that the CI build does not automatically break after a while because
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cc5cf72..e6c31c4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,5 +1,8 @@
 # Contributing to pygount
 
+
+## Project setup
+
 In case you want to play with the source code or contribute changes proceed as
 follows:
 
@@ -15,17 +18,42 @@ follows:
    ```
 3. Install the required packages:
    ```bash
-   $ pip install --upgrade
+   $ pip install --upgrade pip
    $ pip install -r dev-requirements.txt
    ```
 4. Install the pre-commit hook:
    ```bash
    $ pre-commit install
    ```
-5. Run the test suite:
-   ```bash
-   $ pytest
-   ```
 
-Use [black](https://black.readthedocs.io/en/stable/) to format the code or
-simple wait for the pre-commit hook to fix any formatting issues.
+
+## Testing
+
+To run the test suite:
+```bash
+$ pytest
+```
+
+To build and browse the coverage report in HTML format:
+```bash
+$ pytest --cov-report=html
+$ open htmlcov/index.html  # macOS only
+```
+
+## Coding guidelines
+
+The code throughout uses a natural naming schema avoiding abbreviations, even
+for local variables and parameters.
+
+Many coding guidelines are automatically enforced (and some even fixed
+automatically) by the pre-commit hook. If you want to check and clean up
+the code without performing a commit, run:
+```bash
+$ pre-commit run --all-files
+```
+
+In particular, this applies [black](https://black.readthedocs.io/en/stable/),
+[flake8](https://flake8.pycqa.org/) and
+[isort](https://pypi.org/project/isort/).
+
+Other than that
diff --git a/pygount/__init__.py b/pygount/__init__.py
index f6d0c40..e2f7f14 100644
--- a/pygount/__init__.py
+++ b/pygount/__init__.py
@@ -3,7 +3,20 @@ Pygount counts lines of source code using pygments lexers.
 """
 # Copyright (c) 2016-2020, Thomas Aglassinger.
 # All rights reserved. Distributed under the BSD License.
-from pygount.analysis import encoding_for, SourceAnalysis, SourceScanner, source_analysis
-from pygount.common import __version__, Error, OptionError
+from .analysis import encoding_for, DuplicatePool, SourceAnalysis, SourceScanner, SourceState, source_analysis
+from .common import __version__, Error, OptionError
+from .summary import LanguageSummary, ProjectSummary
 
-__all__ = ["encoding_for", "Error", "OptionError", "SourceAnalysis", "SourceScanner", "source_analysis", "__version__"]
+__all__ = [
+    "encoding_for",
+    "DuplicatePool",
+    "Error",
+    "LanguageSummary",
+    "OptionError",
+    "ProjectSummary",
+    "SourceAnalysis",
+    "SourceScanner",
+    "SourceState",
+    "source_analysis",
+    "__version__",
+]
diff --git a/pygount/analysis.py b/pygount/analysis.py
index 7aa3d5d..c2933a4 100644
--- a/pygount/analysis.py
+++ b/pygount/analysis.py
@@ -5,13 +5,14 @@ Functions to analyze source code and count lines in it.
 # All rights reserved. Distributed under the BSD License.
 import codecs
 import collections
-import enum
 import glob
 import hashlib
 import itertools
 import logging
 import os
 import re
+from enum import Enum
+from typing import Optional, List, Dict
 
 import pygments.lexer
 import pygments.lexers
@@ -39,21 +40,17 @@ DEFAULT_FOLDER_PATTERNS_TO_SKIP_TEXT = ", ".join(
     [".*", "_svn", "__pycache__"]  # Subversion hack for Windows  # Python byte code
 )
 
-SourceState = enum.Enum(
-    "SourceState",
-    " ".join(
-        [
-            "analyzed",  # successfully analyzed
-            "binary",  # source code is a binary
-            "duplicate",  # source code is an identical copy of another
-            "empty",  # source code is empty (file size = 0)
-            "error",  # source could not be parsed
-            "generated",  # source code has been generated
-            # TODO: 'huge',  # source code exceeds size limit
-            "unknown",  # pygments does not offer any lexer to analyze the source
-        ]
-    ),
-)
+
+class SourceState(Enum):
+    analyzed = 1  # successfully analyzed
+    binary = 2  # source code is a binary
+    duplicate = 3  # source code is an identical copy of another
+    empty = 4  # source code is empty (file size = 0)
+    error = 5  # source could not be parsed
+    generated = 6  # source code has been generated
+    # TODO: 'huge' = auto()  # source code exceeds size limit
+    unknown = 7  # pygments does not offer any lexer to analyze the source
+
 
 #: Default patterns for regular expressions to detect generated code.
 #: The '(?i)' indicates that the patterns are case insensitive.
@@ -109,10 +106,6 @@ _PLAIN_TEXT_PATTERN = "(^" + "$)|(^".join(_STANDARD_PLAIN_TEXT_NAMES) + "$)"
 #: Regular expression to detect plain text files by name.
 _PLAIN_TEXT_NAME_REGEX = re.compile(_PLAIN_TEXT_PATTERN, re.IGNORECASE)
 
-#: Results of a source analysis derived by :py:func:`source_analysis`.
-SourceAnalysis = collections.namedtuple(
-    "SourceAnalysis", ["path", "language", "group", "code", "documentation", "empty", "string", "state", "state_info"]
-)
 
 #: Mapping for file suffixes to lexers for which pygments offers no official one.
 _SUFFIX_TO_FALLBACK_LEXER_MAP = {
@@ -122,8 +115,92 @@ _SUFFIX_TO_FALLBACK_LEXER_MAP = {
     "vbe": pygount.lexers.MinimalisticVBScriptLexer(),
     "vbs": pygount.lexers.MinimalisticVBScriptLexer(),
 }
-for oracle_suffix in ("pck", "pkb", "pks", "pls"):
-    _SUFFIX_TO_FALLBACK_LEXER_MAP[oracle_suffix] = pygments.lexers.get_lexer_by_name("plpgsql")
+for _oracle_suffix in ("pck", "pkb", "pks", "pls"):
+    _SUFFIX_TO_FALLBACK_LEXER_MAP[_oracle_suffix] = pygments.lexers.get_lexer_by_name("plpgsql")
+
+
+class SourceAnalysis:
+    """
+    Results of a source analysis derived by :py:func:`source_analysis`.
+    """
+
+    def __init__(
+        self,
+        path: str,
+        language: str,
+        group: str,
+        code: int,
+        documentation: int,
+        empty: int,
+        string: int,
+        state: SourceState,
+        state_info: Optional[str] = None,
+    ):
+        self._path = path
+        self._language = language
+        self._group = group
+        self._code = code
+        self._documentation = documentation
+        self._empty = empty
+        self._string = string
+        self._state = state
+        self._state_info = state_info
+
+    @property
+    def path(self) -> str:
+        return self._path
+
+    @property
+    def language(self) -> str:
+        return self._language
+
+    @property
+    def group(self) -> int:
+        return self._group
+
+    @property
+    def code(self) -> int:
+        return self._code
+
+    @property
+    def documentation(self) -> int:
+        return self._documentation
+
+    @property
+    def empty(self) -> int:
+        return self._empty
+
+    @property
+    def string(self) -> int:
+        return self._string
+
+    @property
+    def state(self) -> str:
+        return self._state
+
+    @property
+    def state_info(self) -> str:
+        return self._state_info
+
+    @property
+    def is_countable(self) -> bool:
+        """
+        ``True`` if source counts can be counted towards a total.
+        """
+        return self.state in (SourceState.analyzed, SourceState.duplicate)
+
+    def __repr__(self):
+        result = "{0}(path={1!r}, language={2!r}, group={3!r}, state={4}".format(
+            self.__class__.__name__, self.path, self.language, self.group, self.state.name
+        )
+        if self.state == SourceState.analyzed:
+            result += ", code={0}, documentation={1}, empty={2}, string={3}".format(
+                self.code, self.documentation, self.empty, self.string
+            )
+        if self.state_info is not None:
+            result += ", state_info={0!r}".format(self.state_info)
+        result += ")"
+        return result
 
 
 class SourceScanner:
@@ -232,9 +309,53 @@ class SourceScanner:
                 _log.info("skip due to suffix: %s", source_path)
 
 
+class DuplicatePool:
+    """
+    A pool that collects information about potential duplicate files.
+    """
+
+    def __init__(self):
+        self._size_to_paths_map = {}
+        self._size_and_hash_to_path_map = {}
+
+    @staticmethod
+    def _hash_for(path_to_hash):
+        buffer_size = 1024 * 1024
+        md5_hash = hashlib.md5()
+        with open(path_to_hash, "rb", buffer_size) as file_to_hash:
+            data = file_to_hash.read(buffer_size)
+            while len(data) >= 1:
+                md5_hash.update(data)
+                data = file_to_hash.read(buffer_size)
+        return md5_hash.digest()
+
+    def duplicate_path(self, source_path: str) -> Optional[str]:
+        """
+        Path to a duplicate for ``source_path`` or ``None`` if no duplicate exists.
+
+        Internally information is stored to identify possible future duplicates of
+        ``source_path``.
+        """
+        result = None
+        source_size = os.path.getsize(source_path)
+        paths_with_same_size = self._size_to_paths_map.get(source_size)
+        if paths_with_same_size is None:
+            self._size_to_paths_map[source_size] = [source_path]
+        else:
+            source_hash = DuplicatePool._hash_for(source_path)
+            if len(paths_with_same_size) == 1:
+                # Retrofit the initial path with the same size and its hash.
+                initial_path_with_same_size = paths_with_same_size[0]
+                initial_hash = DuplicatePool._hash_for(initial_path_with_same_size)
+                self._size_and_hash_to_path_map[(source_size, initial_hash)] = initial_path_with_same_size
+            result = self._size_and_hash_to_path_map.get((source_size, source_hash))
+            self._size_and_hash_to_path_map[(source_size, source_hash)] = source_path
+        return result
+
+
 _LANGUAGE_TO_WHITE_WORDS_MAP = {"batchfile": ["@"], "python": ["pass"], "sql": ["begin", "end"]}
-for language in _LANGUAGE_TO_WHITE_WORDS_MAP.keys():
-    assert language.islower()
+for _language in _LANGUAGE_TO_WHITE_WORDS_MAP.keys():
+    assert _language.islower()
 
 
 def matching_number_line_and_regex(source_lines, generated_regexes, max_line_count=15):
@@ -260,7 +381,7 @@ def matching_number_line_and_regex(source_lines, generated_regexes, max_line_cou
     return result
 
 
-def white_characters(language_id):
+def white_characters(language_id) -> str:
     """
     Characters that count as white space if they are the only characters in a
     line.
@@ -270,7 +391,7 @@ def white_characters(language_id):
     return "(),:;[]{}"
 
 
-def white_code_words(language_id):
+def white_code_words(language_id) -> Dict[str, List[str]]:
     """
     Words that do not count as code if it is the only word in a line.
     """
@@ -331,7 +452,7 @@ def _line_parts(lexer, text):
         yield line_marks
 
 
-def encoding_for(source_path, encoding="automatic", fallback_encoding=None):
+def encoding_for(source_path: str, encoding: str = "automatic", fallback_encoding: Optional[str] = None) -> str:
     """
     The encoding used by the text file stored in ``source_path``.
 
@@ -425,7 +546,7 @@ def pseudo_source_analysis(source_path, group, state, state_info=None):
         documentation=0,
         empty=0,
         string=0,
-        state=state.name,
+        state=state,
         state_info=state_info,
     )
 
@@ -457,7 +578,7 @@ def has_lexer(source_path):
     return result
 
 
-def guess_lexer(source_path, text):
+def guess_lexer(source_path: str, text: str) -> pygments.lexer.Lexer:
     if is_plain_text(source_path):
         result = pygount.lexers.PlainTextLexer()
     else:
@@ -475,7 +596,7 @@ def source_analysis(
     encoding="automatic",
     fallback_encoding="cp1252",
     generated_regexes=pygount.common.regexes_from(DEFAULT_GENERATED_PATTERNS_TEXT),
-    duplicate_pool=None,
+    duplicate_pool: Optional[DuplicatePool] = None,
 ):
     """
     Analysis for line counts in source code stored in ``source_path``.
@@ -486,6 +607,9 @@ def source_analysis(
     :param encoding: encoding according to :func:`encoding_for`
     :param fallback_encoding: fallback encoding according to
       :func:`encoding_for`
+    :param generated_regexes: list of regular expression that if found within the first few lines
+      if a source code identify is as generated source code for which SLOC should not be counted
+    :param duplicate_pool:
     :return: a :class:`SourceAnalysis`
     """
     assert encoding is not None
@@ -552,43 +676,9 @@ def source_analysis(
             documentation=mark_to_count_map["d"],
             empty=mark_to_count_map["e"],
             string=mark_to_count_map["s"],
-            state=SourceState.analyzed.name,
+            state=SourceState.analyzed,
             state_info=None,
         )
 
     assert result is not None
     return result
-
-
-class DuplicatePool:
-    def __init__(self):
-        self._size_to_paths_map = {}
-        self._size_and_hash_to_path_map = {}
-
-    @staticmethod
-    def _hash_for(path_to_hash):
-        buffer_size = 1024 * 1024
-        md5_hash = hashlib.md5()
-        with open(path_to_hash, "rb", buffer_size) as file_to_hash:
-            data = file_to_hash.read(buffer_size)
-            while len(data) >= 1:
-                md5_hash.update(data)
-                data = file_to_hash.read(buffer_size)
-        return md5_hash.digest()
-
-    def duplicate_path(self, source_path):
-        result = None
-        source_size = os.path.getsize(source_path)
-        paths_with_same_size = self._size_to_paths_map.get(source_size)
-        if paths_with_same_size is None:
-            self._size_to_paths_map[source_size] = [source_path]
-        else:
-            source_hash = DuplicatePool._hash_for(source_path)
-            if len(paths_with_same_size) == 1:
-                # Retrofit the initial path with the same size and its hash.
-                initial_path_with_same_size = paths_with_same_size[0]
-                initial_hash = DuplicatePool._hash_for(initial_path_with_same_size)
-                self._size_and_hash_to_path_map[(source_size, initial_hash)] = initial_path_with_same_size
-            result = self._size_and_hash_to_path_map.get((source_size, source_hash))
-            self._size_and_hash_to_path_map[(source_size, source_hash)] = source_path
-        return result
diff --git a/pygount/summary.py b/pygount/summary.py
new file mode 100644
index 0000000..5e74838
--- /dev/null
+++ b/pygount/summary.py
@@ -0,0 +1,94 @@
+"""
+Summaries of analyses of multiple source codes.
+"""
+# Copyright (c) 2016-2020, Thomas Aglassinger.
+# All rights reserved. Distributed under the BSD License.
+import functools
+import re
+
+from .analysis import SourceAnalysis
+
+
+_PSEUDO_LANGUAGE_REGEX = re.compile("^__[a-z]+__$")
+
+
+@functools.total_ordering
+class LanguageSummary:
+    """
+    Summary of a source code counts from multiple files of the same language.
+    """
+
+    def __init__(self, language: str):
+        self.language = language
+        self.code = 0
+        self.documentation = 0
+        self.file_count = 0
+        self.empty = 0
+        self.string = 0
+        self.is_pseudo_language = _PSEUDO_LANGUAGE_REGEX.match(self.language) is not None
+
+    def sort_key(self):
+        return self.code, self.documentation, self.string, self.empty, self.language
+
+    def __eq__(self, other):
+        return self.sort_key() == other.sort_key()
+
+    def __lt__(self, other):
+        return self.sort_key() < other.sort_key()
+
+    def add(self, source_analysis: SourceAnalysis):
+        self.file_count += 1
+        if source_analysis.is_countable:
+            self.code += source_analysis.code
+            self.documentation += source_analysis.documentation
+            self.empty += source_analysis.empty
+            self.string += source_analysis.string
+
+    def __repr__(self):
+        result = "{0}(language={1!r}, file_count={2}".format(self.__class__.__name__, self.language, self.file_count)
+        if not self.is_pseudo_language:
+            result += ", code={0}, documentation={1}, empty={2}, string={3}".format(
+                self.code, self.documentation, self.empty, self.string
+            )
+        result += ")"
+        return result
+
+
+class ProjectSummary:
+    """
+    Summary of source code counts for several languages and files.
+    """
+
+    def __init__(self):
+        self.language_to_language_summary_map = {}
+        self.total_code_count = 0
+        self.total_documentation_count = 0
+        self.total_empty_count = 0
+        self.total_string_count = 0
+        self.total_file_count = 0
+        self.total_line_count = 0
+
+    def add(self, source_analysis: SourceAnalysis):
+        self.total_file_count += 1
+        language_summary = self.language_to_language_summary_map.get(source_analysis.language)
+        if language_summary is None:
+            language_summary = LanguageSummary(source_analysis.language)
+            self.language_to_language_summary_map[source_analysis.language] = language_summary
+        language_summary.add(source_analysis)
+
+        if source_analysis.is_countable:
+            self.total_code_count += source_analysis.code
+            self.total_documentation_count += source_analysis.documentation
+            self.total_empty_count += source_analysis.empty
+            self.total_line_count += (
+                source_analysis.code + source_analysis.documentation + source_analysis.empty + source_analysis.string
+            )
+            self.total_string_count += source_analysis.string
+
+    def __repr__(self):
+        return "{0}(total_file_count={1}, total_line_count={2}, " "languages={3}".format(
+            self.__class__.__name__,
+            self.total_file_count,
+            self.total_line_count,
+            sorted(self.language_to_language_summary_map.keys()),
+        )
diff --git a/pygount/write.py b/pygount/write.py
index 794c025..4d1030f 100644
--- a/pygount/write.py
+++ b/pygount/write.py
@@ -4,12 +4,11 @@ Writers to store the resuls of a pygount analysis.
 # Copyright (c) 2016-2020, Thomas Aglassinger.
 # All rights reserved. Distributed under the BSD License.
 import datetime
-import functools
 import math
 import os
 from xml.etree import ElementTree
 
-from pygount.analysis import SourceState
+from .summary import ProjectSummary
 
 
 class BaseWriter:
@@ -19,15 +18,9 @@ class BaseWriter:
             self.target_name = self._target_stream.name
         except AttributeError:
             self.target_name = "<io>"
-        self.source_count = 0
+        self.project_summary = ProjectSummary()
         self.start_time = datetime.datetime.now()
         self.end_time = None
-        self.total_code_count = 0
-        self.total_documentation_count = 0
-        self.total_empty_count = 0
-        self.total_string_count = 0
-        self.total_source_count = 0
-        self.total_line_count = 0
         self.sources_per_second = 0
         self.lines_per_second = 0
         self.duration = None
@@ -40,14 +33,7 @@ class BaseWriter:
         self.close()
 
     def add(self, source_analysis):
-        self.total_code_count += source_analysis.code
-        self.total_documentation_count += source_analysis.documentation
-        self.total_empty_count += source_analysis.empty
-        self.total_source_count += 1
-        self.total_line_count += (
-            source_analysis.code + source_analysis.documentation + source_analysis.empty + source_analysis.string
-        )
-        self.total_string_count += source_analysis.string
+        self.project_summary.add(source_analysis)
 
     def close(self):
         self.end_time = datetime.datetime.now()
@@ -56,8 +42,8 @@ class BaseWriter:
             (1e-6 * self.duration.microseconds) + self.duration.seconds + self.duration.days * 3600 * 24
         )
         if self.duration_in_seconds > 0:
-            self.lines_per_second = self.total_line_count / self.duration_in_seconds
-            self.sources_per_second = self.total_source_count / self.duration_in_seconds
+            self.lines_per_second = self.project_summary.total_file_count / self.duration_in_seconds
+            self.sources_per_second = self.project_summary.total_file_count / self.duration_in_seconds
 
 
 class LineWriter(BaseWriter):
@@ -112,17 +98,17 @@ class ClocXmlWriter(BaseWriter):
         super().close()
         # Add various statistics to <header>.
         ElementTree.SubElement(self._header_element, "elapsed_seconds", text="%.f" % self.duration_in_seconds)
-        ElementTree.SubElement(self._header_element, "n_files", text="%d" % self.total_source_count)
-        ElementTree.SubElement(self._header_element, "n_lines", text="%d" % self.total_line_count)
+        ElementTree.SubElement(self._header_element, "n_files", text="%d" % self.project_summary.total_file_count)
+        ElementTree.SubElement(self._header_element, "n_lines", text="%d" % self.project_summary.total_line_count)
         ElementTree.SubElement(self._header_element, "files_per_second", text="%.f" % self.sources_per_second)
         ElementTree.SubElement(self._header_element, "lines_per_second", text="%.f" % self.lines_per_second)
         ElementTree.SubElement(self._header_element, "report_file", text=self.target_name)
 
         # Add totals to <files>.
         file_attributes = {
-            "blank": str(self.total_empty_count),
-            "code": str(self.total_code_count + self.total_string_count),
-            "comment": str(self.total_documentation_count),
+            "blank": str(self.project_summary.total_empty_count),
+            "code": str(self.project_summary.total_code_count + self.project_summary.total_string_count),
+            "comment": str(self.project_summary.total_documentation_count),
         }
         ElementTree.SubElement(self._files_element, "total", attrib=file_attributes)
 
@@ -134,26 +120,6 @@ class ClocXmlWriter(BaseWriter):
         xml_root.write(self._target_stream, encoding="unicode", xml_declaration=False)
 
 
-#: Language statistics collected by ``SummaryWriter``.
-@functools.total_ordering
-class _LanguageStatistics:
-    def __init__(self, language):
-        self.language = language
-        self.code = 0
-        self.documentation = 0
-        self.empty = 0
-        self.string = 0
-
-    def sort_key(self):
-        return self.code, self.documentation, self.string, self.empty, self.language
-
-    def __eq__(self, other):
-        return self.sort_key() == other.sort_key()
-
-    def __lt__(self, other):
-        return self.sort_key() < other.sort_key()
-
-
 class SummaryWriter(BaseWriter):
     """
     Writer to summarize the analysis per language in a format that can easily
@@ -161,67 +127,62 @@ class SummaryWriter(BaseWriter):
     """
 
     _LANGUAGE_HEADING = "Language"
+    _FILE_COUNT_HEADING = "Files"
     _CODE_HEADING = "Code"
     _DOCUMENTATION_HEADING = "Comment"
     _SUM_TOTAL_PSEUDO_LANGUAGE = "Sum total"
+    _PERCENTAGE_DIGITS_AFTER_DOT = 2
 
     def __init__(self, target_stream):
         super().__init__(target_stream)
-        self._language_to_language_statistics_map = {}
         self._max_language_width = max(
             len(SummaryWriter._LANGUAGE_HEADING), len(SummaryWriter._SUM_TOTAL_PSEUDO_LANGUAGE)
         )
         self._max_code_width = 0
         self._max_documentation_width = 0
 
-    def add(self, source_analysis):
-        super().add(source_analysis)
-        if source_analysis.state in (SourceState.analyzed.name, SourceState.duplicate.name):
-            language_statistics = self._language_to_language_statistics_map.get(source_analysis.language)
-            if language_statistics is None:
-                language_statistics = _LanguageStatistics(source_analysis.language)
-                self._language_to_language_statistics_map[source_analysis.language] = language_statistics
-            language_statistics.code += source_analysis.code
-            language_statistics.documentation += source_analysis.documentation
-            language_statistics.empty += source_analysis.empty
-            language_statistics.string += source_analysis.string
-
     def close(self):
         super().close()
 
         # Compute maximum column widths
         max_language_width = max(len(SummaryWriter._LANGUAGE_HEADING), len(SummaryWriter._SUM_TOTAL_PSEUDO_LANGUAGE),)
+        max_file_count_width = len(SummaryWriter._FILE_COUNT_HEADING)
         max_code_width = len(SummaryWriter._CODE_HEADING)
         max_documentation_width = len(SummaryWriter._DOCUMENTATION_HEADING)
-        for language, language_statistics in self._language_to_language_statistics_map.items():
+        for language, language_summary in self.project_summary.language_to_language_summary_map.items():
             language_width = len(language)
             if language_width > max_language_width:
                 max_language_width = language_width
-            code_width = digit_width(language_statistics.code)
+            file_count_width = digit_width(language_summary.file_count)
+            if file_count_width > max_file_count_width:
+                max_file_count_width = file_count_width
+            code_width = digit_width(language_summary.code)
             if code_width > max_code_width:
                 max_code_width = code_width
-            documentation_width = digit_width(language_statistics.documentation)
+            documentation_width = digit_width(language_summary.documentation)
             if documentation_width > max_documentation_width:
                 max_documentation_width = documentation_width
-        percentage_width = 6
-        digits_after_dot = 2
-        max_digits_before_dot = percentage_width - digits_after_dot - 1
-        assert max_digits_before_dot >= 3  # Must be able to hold "100".
+        digits_after_dot = self._PERCENTAGE_DIGITS_AFTER_DOT
+        percentage_width = 4 + digits_after_dot  # To represent "100." we need 4 characters.
 
         summary_heading = (
             "{0:^{max_language_width}s}  "
-            "{1:^{max_code_width}s}  "
+            "{1:^{max_file_count_width}s}  "
+            "{2:^{percentage_width}s}  "
+            "{3:^{max_code_width}s}  "
             "{2:^{percentage_width}s}  "
-            "{3:^{max_documentation_width}s}  "
+            "{4:^{max_documentation_width}s}  "
             "{2:^{percentage_width}s}"
         ).format(
             SummaryWriter._LANGUAGE_HEADING,
-            SummaryWriter._CODE_HEADING,
+            SummaryWriter._FILE_COUNT_HEADING,
             "%",
+            SummaryWriter._CODE_HEADING,
             SummaryWriter._DOCUMENTATION_HEADING,
+            max_code_width=max_code_width,
+            max_file_count_width=max_file_count_width,
             max_documentation_width=max_documentation_width,
             max_language_width=max_language_width,
-            max_code_width=max_code_width,
             percentage_width=percentage_width,
         )
         self._target_stream.write(summary_heading + os.linesep)
@@ -230,6 +191,8 @@ class SummaryWriter(BaseWriter):
                 "-" * width
                 for width in (
                     max_language_width,
+                    max_file_count_width,
+                    percentage_width,
                     max_code_width,
                     percentage_width,
                     max_documentation_width,
@@ -240,44 +203,62 @@ class SummaryWriter(BaseWriter):
         self._target_stream.write(separator_line + os.linesep)
         language_line_template = (
             "{0:{max_language_width}s}  "
-            "{1:>{max_code_width}d}  "
+            "{1:>{max_file_count_width}d}  "
             "{2:>{percentage_width}.0{digits_after_dot}f}  "
-            "{3:>{max_documentation_width}d}  "
-            "{4:>{percentage_width}.0{digits_after_dot}f}"
+            "{3:>{max_code_width}d}  "
+            "{4:>{percentage_width}.0{digits_after_dot}f}  "
+            "{5:>{max_documentation_width}d}  "
+            "{6:>{percentage_width}.0{digits_after_dot}f}"
         )
-        for language_statistics in sorted(self._language_to_language_statistics_map.values(), reverse=True):
-            code_count = language_statistics.code
-            code_percentage = code_count / self.total_code_count * 100 if self.total_code_count != 0 else 0.0
+        for language_summary in sorted(self.project_summary.language_to_language_summary_map.values(), reverse=True):
+            code_count = language_summary.code
+            code_percentage = (
+                code_count / self.project_summary.total_code_count * 100
+                if self.project_summary.total_code_count != 0
+                else 0.0
+            )
+            file_count = language_summary.file_count
+            assert (
+                self.project_summary.total_file_count != 0
+            ), "if there is at least 1 language summary, there must be a file count too"
+            file_percentage = file_count / self.project_summary.total_file_count * 100
             documentation_percentage = (
-                language_statistics.documentation / self.total_documentation_count * 100
-                if self.total_documentation_count != 0
+                language_summary.documentation / self.project_summary.total_documentation_count * 100
+                if self.project_summary.total_documentation_count != 0
                 else 0.0
             )
             line_to_write = language_line_template.format(
-                language_statistics.language,
+                language_summary.language,
+                file_count,
+                file_percentage,
                 code_count,
                 code_percentage,
-                language_statistics.documentation,
+                language_summary.documentation,
                 documentation_percentage,
                 digits_after_dot=digits_after_dot,
+                max_code_width=max_code_width,
                 max_documentation_width=max_documentation_width,
+                max_file_count_width=max_file_count_width,
                 max_language_width=max_language_width,
-                max_code_width=max_code_width,
                 percentage_width=percentage_width,
             )
             self._target_stream.write(line_to_write + os.linesep)
         self._target_stream.write(separator_line + os.linesep)
         summary_footer = (
             "{0:{max_language_width}s}  "
-            "{1:>{max_code_width}d}  "
+            "{1:>{max_file_count_width}d}  "
+            "{2:{percentage_width}s}  "
+            "{3:>{max_code_width}d}  "
             "{2:{percentage_width}s}  "
-            "{3:>{max_documentation_width}d}"
+            "{4:>{max_documentation_width}d}"
         ).format(
             SummaryWriter._SUM_TOTAL_PSEUDO_LANGUAGE,
-            self.total_code_count,
+            self.project_summary.total_file_count,
             "",
-            self.total_documentation_count,
+            self.project_summary.total_code_count,
+            self.project_summary.total_documentation_count,
             max_documentation_width=max_documentation_width,
+            max_file_count_width=max_file_count_width,
             max_language_width=max_language_width,
             max_code_width=max_code_width,
             percentage_width=percentage_width,


Test patch:
diff --git a/tests/test_analysis.py b/tests/test_analysis.py
index 955244f..fc9ae00 100644
--- a/tests/test_analysis.py
+++ b/tests/test_analysis.py
@@ -12,6 +12,7 @@ from pygments import lexers, token
 
 from ._common import PYGOUNT_PROJECT_FOLDER, PYGOUNT_SOURCE_FOLDER, TempFolderTest
 from .test_xmldialect import EXAMPLE_ANT_CODE
+from pygount.analysis import guess_lexer
 from pygount import analysis
 from pygount import common
 
@@ -104,7 +105,7 @@ class FileAnalysisTest(TempFolderTest):
         test_path = self.create_temp_file("encoding_error.py", 'print("\N{EURO SIGN}")', encoding="cp1252")
         source_analysis = analysis.source_analysis(test_path, "test", encoding="utf-8")
         assert source_analysis.language == "__error__"
-        assert source_analysis.state == analysis.SourceState.error.name
+        assert source_analysis.state == analysis.SourceState.error
         assert "0x80" in str(source_analysis.state_info)
 
     def test_can_detect_silent_dos_batch_remarks(self):
@@ -125,7 +126,7 @@ class FileAnalysisTest(TempFolderTest):
         assert no_such_encoding == "no_such_encoding"
         source_analysis = analysis.source_analysis(test_path, "test", encoding=no_such_encoding)
         assert source_analysis.language == "__error__"
-        assert source_analysis.state == analysis.SourceState.error.name
+        assert source_analysis.state == analysis.SourceState.error
         assert "unknown encoding" in str(source_analysis.state_info)
 
     def test_can_analyze_oracle_sql(self):
@@ -149,21 +150,61 @@ class FileAnalysisTest(TempFolderTest):
     def test_can_analyze_xml_dialect(self):
         build_xml_path = self.create_temp_file("build.xml", EXAMPLE_ANT_CODE)
         source_analysis = analysis.source_analysis(build_xml_path, "test")
-        assert source_analysis.state == analysis.SourceState.analyzed.name
+        assert source_analysis.state == analysis.SourceState.analyzed
         assert source_analysis.language == "Ant"
 
     def test_can_analyze_unknown_language(self):
         unknown_language_path = self.create_temp_file("some.unknown_language", ["some", "lines", "of", "text"])
         source_analysis = analysis.source_analysis(unknown_language_path, "test")
-        assert source_analysis.state == analysis.SourceState.unknown.name
+        assert source_analysis.state == analysis.SourceState.unknown
 
     def test_can_detect_binary_source_code(self):
         binary_path = self.create_temp_binary_file("some_django.mo", b"hello\0world!")
         source_analysis = analysis.source_analysis(binary_path, "test", encoding="utf-8")
-        assert source_analysis.state == analysis.SourceState.binary.name
+        assert source_analysis.state == analysis.SourceState.binary
         assert source_analysis.code == 0
 
 
+def test_can_repr_source_analysis():
+    source_analysis = analysis.SourceAnalysis("some.py", "Python", "some", 1, 2, 3, 4, analysis.SourceState.analyzed)
+    expected_source_analysis_repr = (
+        "SourceAnalysis(path='some.py', language='Python', group='some', "
+        "state=analyzed, code=1, documentation=2, empty=3, string=4)"
+    )
+    assert repr(source_analysis) == expected_source_analysis_repr
+    assert repr(source_analysis) == str(source_analysis)
+
+
+def test_can_repr_empty_source_analysis():
+    source_analysis = analysis.SourceAnalysis("some.py", "__empty__", "some", 0, 0, 0, 0, analysis.SourceState.empty)
+    expected_source_analysis_repr = "SourceAnalysis(path='some.py', language='__empty__', group='some', state=empty)"
+    assert repr(source_analysis) == expected_source_analysis_repr
+    assert repr(source_analysis) == str(source_analysis)
+
+
+def test_can_repr_error_source_analysis():
+    source_analysis = analysis.SourceAnalysis(
+        "some.py", "__error__", "some", 0, 0, 0, 0, analysis.SourceState.error, "error details"
+    )
+    expected_source_analysis_repr = (
+        "SourceAnalysis(path='some.py', language='__error__', group='some', state=error, state_info='error details')"
+    )
+    assert repr(source_analysis) == expected_source_analysis_repr
+    assert repr(source_analysis) == str(source_analysis)
+
+
+def test_can_guess_lexer_for_python():
+    lexer = guess_lexer("some.py", "pass")
+    assert lexer is not None
+    assert lexer.name == "Python"
+
+
+def test_can_guess_lexer_for_plain_text():
+    lexer = guess_lexer("README.1st", "hello!")
+    assert lexer is not None
+    assert lexer.name == "Text"
+
+
 class EncodingTest(TempFolderTest):
     _ENCODING_TO_BOM_MAP = dict((encoding, bom) for bom, encoding in analysis._BOM_TO_ENCODING_MAP.items())
     _TEST_CODE = "x = '\u00fd \u20ac'"
@@ -282,14 +323,14 @@ class GeneratedCodeTest(TempFolderTest):
         source_analysis = analysis.source_analysis(
             generated_sql_path, "test", generated_regexes=common.regexes_from("[regex](?i).*generiert")
         )
-        assert source_analysis.state == analysis.SourceState.generated.name
+        assert source_analysis.state == analysis.SourceState.generated
 
 
 class SizeTest(TempFolderTest):
     def test_can_detect_empty_source_code(self):
         empty_py_path = self.create_temp_binary_file("empty.py", b"")
         source_analysis = analysis.source_analysis(empty_py_path, "test", encoding="utf-8")
-        assert source_analysis.state == analysis.SourceState.empty.name
+        assert source_analysis.state == analysis.SourceState.empty
         assert source_analysis.code == 0
 
 
@@ -297,7 +338,7 @@ def test_can_analyze_project_markdown_files():
     project_root_folder = os.path.dirname(PYGOUNT_PROJECT_FOLDER)
     for text_path in glob.glob(os.path.join(project_root_folder, "*.md")):
         source_analysis = analysis.source_analysis(text_path, "test")
-        assert source_analysis.state == analysis.SourceState.analyzed.name
+        assert source_analysis.state == analysis.SourceState.analyzed
         assert source_analysis.documentation > 0
         assert source_analysis.empty > 0
 
diff --git a/tests/test_summary.py b/tests/test_summary.py
new file mode 100644
index 0000000..ce20e4c
--- /dev/null
+++ b/tests/test_summary.py
@@ -0,0 +1,85 @@
+"""
+Tests to summarize analyses of multiple source codes.
+"""
+from pygount.analysis import SourceAnalysis, SourceState
+from pygount.summary import LanguageSummary, ProjectSummary
+
+
+def test_can_repr_language_summary():
+    language_summary = LanguageSummary("Python")
+    language_summary.add(SourceAnalysis("some.py", "Python", "some", 2, 3, 4, 5, SourceState.analyzed))
+    expected_language_summary_repr = (
+        "LanguageSummary(language='Python', file_count=1, code=2, documentation=3, empty=4, string=5)"
+    )
+    assert repr(language_summary) == expected_language_summary_repr
+    assert repr(language_summary) == str(language_summary)
+
+
+def test_can_repr_pseudo_language_summary():
+    language_summary = LanguageSummary("__empty__")
+    language_summary.add(SourceAnalysis("some.py", "__empty__", "some", 0, 0, 0, 0, SourceState.empty))
+    expected_language_summary_repr = "LanguageSummary(language='__empty__', file_count=1)"
+    assert repr(language_summary) == expected_language_summary_repr
+    assert repr(language_summary) == str(language_summary)
+
+
+def test_can_summarize_project_with_multiple_files_of_same_language():
+    source_analyses = (
+        SourceAnalysis("some.py", "Python", "some", 300, 70, 4, 2, SourceState.analyzed),
+        SourceAnalysis("other.py", "Python", "some", 700, 30, 6, 3, SourceState.analyzed),
+    )
+
+    project_summary = ProjectSummary()
+    for source_analysis in source_analyses:
+        project_summary.add(source_analysis)
+
+    assert set(project_summary.language_to_language_summary_map.keys()) == {"Python"}
+    assert project_summary.total_file_count == 2
+    assert project_summary.total_code_count == 1000
+    assert project_summary.total_documentation_count == 100
+    assert project_summary.total_empty_count == 10
+    assert project_summary.total_string_count == 5
+
+
+def test_can_summarize_project_with_multiple_files_of_different_languages():
+    source_analyses = (
+        SourceAnalysis("some.py", "Python", "some", 1000, 100, 10, 3, SourceState.analyzed),
+        SourceAnalysis("some.sh", "Bash", "some", 200, 20, 5, 2, SourceState.analyzed),
+    )
+
+    project_summary = ProjectSummary()
+    for source_analysis in source_analyses:
+        project_summary.add(source_analysis)
+
+    assert set(project_summary.language_to_language_summary_map.keys()) == {"Bash", "Python"}
+    assert project_summary.total_file_count == 2
+    assert project_summary.total_code_count == 1200
+    assert project_summary.total_documentation_count == 120
+    assert project_summary.total_empty_count == 15
+    assert project_summary.total_string_count == 5
+
+
+def test_can_summarize_project_with_pseudo_languages():
+    source_analyses = (
+        SourceAnalysis("empty.py", "__empty__", "some", 0, 0, 0, 0, SourceState.empty),
+        SourceAnalysis("generated.py", "__generated__", "some", 1, 2, 3, 4, SourceState.generated),
+        SourceAnalysis("binary .bin", "__binary__", "some", 0, 0, 0, 0, SourceState.binary),
+    )
+    expected_languages = {source_analysis.language for source_analysis in source_analyses}
+
+    project_summary = ProjectSummary()
+    for source_analysis in source_analyses:
+        project_summary.add(source_analysis)
+
+    assert project_summary.total_file_count == 3
+    assert set(project_summary.language_to_language_summary_map.keys()) == expected_languages
+    assert project_summary.total_code_count == 0
+    assert project_summary.total_documentation_count == 0
+    assert project_summary.total_empty_count == 0
+    assert project_summary.total_string_count == 0
+
+
+def test_can_repr_empty_project_summary():
+    project_summary = ProjectSummary()
+    assert repr(project_summary) == "ProjectSummary(total_file_count=0, total_line_count=0, languages=[]"
+    assert repr(project_summary) == str(project_summary)
diff --git a/tests/test_write.py b/tests/test_write.py
index 5f138a3..3ef2ab7 100644
--- a/tests/test_write.py
+++ b/tests/test_write.py
@@ -11,28 +11,27 @@ from collections import namedtuple
 import pytest
 from xml.etree import ElementTree
 
-from pygount import analysis
-from pygount import write
+from pygount import analysis, write
 from ._common import TempFolderTest
 
 
 def test_can_collect_totals():
     source_analyses = (
-        analysis.SourceAnalysis("some.py", "Python", "some", 1, 2, 3, 4, analysis.SourceState.analyzed.name, None),
-        analysis.SourceAnalysis("other.py", "Python", "some", 10, 20, 30, 40, analysis.SourceState.analyzed.name, None),
+        analysis.SourceAnalysis("some.py", "Python", "some", 1, 2, 3, 4, analysis.SourceState.analyzed, None),
+        analysis.SourceAnalysis("other.py", "Python", "some", 10, 20, 30, 40, analysis.SourceState.analyzed, None),
     )
     with tempfile.NamedTemporaryFile("w", encoding="utf-8", prefix="pygount_", suffix=".tmp") as target_stream:
         with write.BaseWriter(target_stream) as writer:
             for source_analysis in source_analyses:
                 writer.add(source_analysis)
-    assert writer.total_source_count == 2
-    assert writer.total_line_count == 110
+    assert writer.project_summary.total_file_count == 2
+    assert writer.project_summary.total_line_count == 110
 
 
 def test_can_write_cloc_xml():
     source_analyses = (
-        analysis.SourceAnalysis("some.py", "Python", "some", 1, 2, 3, 4, analysis.SourceState.analyzed.name, None),
-        analysis.SourceAnalysis("other.py", "Python", "some", 10, 20, 30, 40, analysis.SourceState.analyzed.name, None),
+        analysis.SourceAnalysis("some.py", "Python", "some", 1, 2, 3, 4, analysis.SourceState.analyzed, None),
+        analysis.SourceAnalysis("other.py", "Python", "some", 10, 20, 30, 40, analysis.SourceState.analyzed, None),
     )
     with io.StringIO() as target_stream:
         with write.ClocXmlWriter(target_stream) as writer:
@@ -56,43 +55,58 @@ def test_can_compute_digit_width():
 
 
 _LineData = namedtuple(
-    "_LineData", ["language", "code_count", "code_percent", "documentation_count", "documentation_percent"]
+    "_LineData",
+    [
+        "language",
+        "file_count",
+        "file_percent",
+        "code_count",
+        "code_percent",
+        "documentation_count",
+        "documentation_percent",
+    ],
 )
 
 
 def _line_data_from(line):
     line_parts = line.split()
-    assert len(line_parts) == 5, "line_parts={0}".format(line_parts)
-    return _LineData(line_parts[0], int(line_parts[1]), float(line_parts[2]), int(line_parts[3]), float(line_parts[4]),)
+    assert len(line_parts) == 7, "line_parts={0}".format(line_parts)
+    return _LineData(
+        line_parts[0],
+        int(line_parts[1]),
+        float(line_parts[2]),
+        int(line_parts[3]),
+        float(line_parts[4]),
+        int(line_parts[5]),
+        float(line_parts[6]),
+    )
 
 
 class SummaryWriterTest(TempFolderTest):
     def test_can_write_summary(self):
         source_analyses = (
-            analysis.SourceAnalysis(
-                "script.sh", "Bash", "some", 200, 25, 1, 2, analysis.SourceState.analyzed.name, None
-            ),
-            analysis.SourceAnalysis(
-                "some.py", "Python", "some", 300, 45, 3, 4, analysis.SourceState.analyzed.name, None
-            ),
-            analysis.SourceAnalysis(
-                "other.py", "Python", "some", 500, 30, 5, 6, analysis.SourceState.analyzed.name, None
-            ),
+            analysis.SourceAnalysis("script.sh", "Bash", "some", 200, 25, 1, 2, analysis.SourceState.analyzed, None),
+            analysis.SourceAnalysis("some.py", "Python", "some", 300, 45, 3, 4, analysis.SourceState.analyzed, None),
+            analysis.SourceAnalysis("other.py", "Python", "some", 500, 30, 5, 6, analysis.SourceState.analyzed, None),
         )
         lines = self._summary_lines_for(source_analyses)
         assert len(lines) == 6, "lines={}".format(lines)
 
         python_data = _line_data_from(lines[2])
         assert python_data.language == "Python"
+        assert python_data.file_count == 2
         assert python_data.code_count == 800
         assert python_data.documentation_count == 75
+        assert python_data.file_percent == pytest.approx(66.67)
         assert python_data.code_percent == pytest.approx(80.0)
         assert python_data.documentation_percent == pytest.approx(75.0)
 
         bash_data = _line_data_from(lines[3])
         assert bash_data.language == "Bash"
+        assert bash_data.file_count == 1
         assert bash_data.code_count == 200
         assert bash_data.documentation_count == 25
+        assert bash_data.file_percent == pytest.approx(33.33)
         assert bash_data.code_percent == pytest.approx(20.0)
         assert bash_data.documentation_percent == pytest.approx(25.0)
 
@@ -120,15 +134,7 @@ class SummaryWriterTest(TempFolderTest):
 
         source_analyses = (
             analysis.SourceAnalysis(
-                "x",
-                long_language_name,
-                "some",
-                huge_number,
-                huge_number,
-                0,
-                0,
-                analysis.SourceState.analyzed.name,
-                None,
+                "x", long_language_name, "some", huge_number, huge_number, 0, 0, analysis.SourceState.analyzed, None,
             ),
         )
         for line in self._summary_lines_for(source_analyses):