diff --git a/tests/test_badges.py b/tests/test_badges.py
new file mode 100644
index 0000000..e980169
--- /dev/null
+++ b/tests/test_badges.py
@@ -0,0 +1,282 @@
+"""Offline tests for tools/badges.py (ADR-0010): the coverage reader and the SVG renderer."""
+
+import importlib.util
+import json
+import xml.etree.ElementTree as ElementTree
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+_SPEC = importlib.util.spec_from_file_location(
+ "badges", REPO_ROOT / "tools" / "badges.py"
+)
+assert _SPEC is not None and _SPEC.loader is not None
+badges = importlib.util.module_from_spec(_SPEC)
+_SPEC.loader.exec_module(badges)
+
+DTD = (
+ ''
+)
+
+
+def report(
+ tmp_path: Path, attributes: str, root: str = "coverage", prolog: str = ""
+) -> Path:
+ path = tmp_path / "coverage.xml"
+ path.write_text(
+ f'\n{prolog}<{root} {attributes}>{root}>\n'
+ )
+ return path
+
+
+class TestReadCoverage:
+ @pytest.mark.parametrize(
+ ("covered", "valid", "percent"),
+ [
+ (0, 10, 0),
+ (1, 3, 33),
+ (2, 3, 67),
+ (5, 8, 63),
+ (87, 100, 87),
+ (1, 200, 1),
+ (10, 10, 100),
+ ],
+ )
+ def test_percent_is_covered_over_valid_rounded_half_up(
+ self, tmp_path, covered, valid, percent
+ ):
+ path = report(
+ tmp_path, f'lines-covered="{covered}" lines-valid="{valid}" line-rate="0"'
+ )
+ assert badges.read_coverage(path) == percent
+
+ def test_the_half_rounds_up(self, tmp_path):
+ assert (
+ badges.read_coverage(report(tmp_path, 'lines-covered="1" lines-valid="8"'))
+ == 13
+ )
+
+ def test_line_rate_is_used_when_the_counts_are_absent(self, tmp_path):
+ assert badges.read_coverage(report(tmp_path, 'line-rate="0.875"')) == 88
+
+ def test_a_document_type_declaration_is_accepted(self, tmp_path):
+ path = report(tmp_path, 'lines-covered="9" lines-valid="10"', prolog=DTD + "\n")
+ assert badges.read_coverage(path) == 90
+
+ def test_missing_file_names_the_file(self, tmp_path):
+ with pytest.raises(badges.ConfigurationError, match="nope.xml' does not exist"):
+ badges.read_coverage(tmp_path / "nope.xml")
+
+ def test_malformed_xml_names_the_file(self, tmp_path):
+ path = tmp_path / "bad.xml"
+ path.write_text(" Path:
+ path = tmp_path / "metrics.json"
+ path.write_text(
+ json.dumps(
+ {"files": files, "lines": lines, "comment": 5, "blank": 6, "languages": {}}
+ )
+ )
+ return path
+
+
+class TestMain:
+ def test_writes_three_badges_and_the_metrics(self, tmp_path, capsys):
+ out = tmp_path / "out"
+ cov = report(tmp_path, 'lines-covered="87" lines-valid="100"')
+ code = badges.main(
+ [
+ "--metrics",
+ str(metrics_file(tmp_path)),
+ "--output-dir",
+ str(out),
+ "--coverage-report",
+ str(cov),
+ ]
+ )
+ assert code == 0
+ assert sorted(p.name for p in out.iterdir()) == [
+ "code-files.svg",
+ "code-lines.svg",
+ "code-metrics.json",
+ "coverage.svg",
+ ]
+ assert (
+ 'aria-label="lines of code: 1,200"' in (out / "code-lines.svg").read_text()
+ )
+ assert 'aria-label="coverage: 87%"' in (out / "coverage.svg").read_text()
+ assert f'fill="{badges.GREEN}"' in (out / "coverage.svg").read_text()
+ assert b"\r" not in (out / "code-files.svg").read_bytes()
+ assert "wrote" in capsys.readouterr().out
+
+ def test_output_is_byte_identical_on_a_second_run(self, tmp_path):
+ args = [
+ "--metrics",
+ str(metrics_file(tmp_path)),
+ "--output-dir",
+ str(tmp_path / "o"),
+ ]
+ badges.main(args)
+ first = {p.name: p.read_bytes() for p in (tmp_path / "o").iterdir()}
+ badges.main(args)
+ assert first == {p.name: p.read_bytes() for p in (tmp_path / "o").iterdir()}
+
+ def test_without_a_report_no_coverage_badge_and_a_notice(self, tmp_path, capsys):
+ out = tmp_path / "out"
+ assert (
+ badges.main(
+ ["--metrics", str(metrics_file(tmp_path)), "--output-dir", str(out)]
+ )
+ == 0
+ )
+ assert not (out / "coverage.svg").exists()
+ assert "::notice::No coverage-report given" in capsys.readouterr().out
+
+ def test_a_bad_report_fails_and_writes_nothing(self, tmp_path, capsys):
+ out = tmp_path / "out"
+ code = badges.main(
+ [
+ "--metrics",
+ str(metrics_file(tmp_path)),
+ "--output-dir",
+ str(out),
+ "--coverage-report",
+ str(tmp_path / "missing.xml"),
+ ]
+ )
+ assert code == 1
+ assert "::error::[configuration] coverage-report" in capsys.readouterr().out
+ assert not out.exists()
+
+ @pytest.mark.parametrize(
+ ("warn", "good", "message"),
+ [
+ ("x", "80", "coverage-warn 'x'"),
+ ("60", "101", "coverage-good '101'"),
+ ("-1", "80", "coverage-warn '-1'"),
+ ("90", "80", "above coverage-good"),
+ ],
+ )
+ def test_invalid_thresholds_fail(self, tmp_path, capsys, warn, good, message):
+ code = badges.main(
+ [
+ "--metrics",
+ str(metrics_file(tmp_path)),
+ "--output-dir",
+ str(tmp_path / "o"),
+ "--coverage-warn",
+ warn,
+ "--coverage-good",
+ good,
+ ]
+ )
+ assert code == 1
+ assert message in capsys.readouterr().out
+
+ def test_unreadable_metrics_fail(self, tmp_path, capsys):
+ bad = tmp_path / "m.json"
+ bad.write_text('{"files": 1}')
+ code = badges.main(["--metrics", str(bad), "--output-dir", str(tmp_path / "o")])
+ assert code == 1
+ assert "cannot be read" in capsys.readouterr().out
diff --git a/tests/test_count_loc.py b/tests/test_count_loc.py
new file mode 100644
index 0000000..cc7ab6e
--- /dev/null
+++ b/tests/test_count_loc.py
@@ -0,0 +1,317 @@
+"""Offline tests for tools/count_loc.py (ADR-0010): hand-counted fixtures per language."""
+
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+_SPEC = importlib.util.spec_from_file_location(
+ "count_loc", REPO_ROOT / "tools" / "count_loc.py"
+)
+assert _SPEC is not None and _SPEC.loader is not None
+count_loc = importlib.util.module_from_spec(_SPEC)
+_SPEC.loader.exec_module(count_loc)
+
+LANGUAGE = {language.name: language for language in count_loc.LANGUAGES}
+
+# name -> (source, code, comment, blank), counted by hand.
+FIXTURES = {
+ "Python": (
+ "# a comment\n"
+ "x = 1 # trailing comment\n"
+ "\n"
+ 's = "a # not a comment"\n'
+ "t = 'it''s # still a string'\n"
+ '"""docstring\n'
+ "# inside a string\n"
+ "\n"
+ '"""\n'
+ "y = 2", # no final newline
+ 7,
+ 1,
+ 2,
+ ),
+ "Shell": (
+ "#!/usr/bin/env bash\n"
+ "# comment\n"
+ 'echo "$#" ${#args[@]} a#b\n'
+ "\n"
+ " # indented comment\n"
+ "echo 'it # stays' # but this is a comment\n"
+ "ls | wc -l;# after a semicolon\n",
+ 3,
+ 3,
+ 1,
+ ),
+ "PowerShell": (
+ '<# block\ncomment #>\nWrite-Host "a # b"\n# line\n$x = 1 # trailing\n',
+ 2,
+ 3,
+ 0,
+ ),
+ "C": (
+ "/* header\n"
+ " * text\n"
+ " */\n"
+ "#include \n"
+ "\n"
+ "int main(void) { /* inline */ return 0; }\n"
+ "char quote = '\"'; // a double quote in a char\n"
+ 'const char *url = "http://example.com";\n'
+ "// done\n",
+ 4,
+ 4,
+ 1,
+ ),
+ "C++": (
+ 'int a = 0; // one\n/* block */\nauto s = "/* not a comment */";\n',
+ 2,
+ 1,
+ 0,
+ ),
+ "C#": (
+ "// line\n"
+ "using System;\n"
+ "/// doc\n"
+ 'class A { string s = "// no"; }\n',
+ 2,
+ 2,
+ 0,
+ ),
+ "Java": (
+ "/** doc */\nclass A {\n // c\n int x; /* t */\n}\n",
+ 3,
+ 2,
+ 0,
+ ),
+ "JavaScript": (
+ "const a = `line one\n"
+ "// in a template\n"
+ "line three`;\n"
+ "// real comment\n"
+ "const b = 'x // y';\n"
+ "/* a */ /* b */\n",
+ 4,
+ 2,
+ 0,
+ ),
+ "TypeScript": (
+ "let n: number = 1; // c\n\n/*\n * block\n */\n",
+ 1,
+ 3,
+ 1,
+ ),
+ "Go": (
+ "package main\n// c\nvar r = '/'\nvar raw = `// not a comment\n`\n",
+ 4,
+ 1,
+ 0,
+ ),
+ "Rust": (
+ "/* outer /* nested */ still a comment */\n"
+ "fn f<'a>(x: &'a str) -> &'a str { x } // lifetimes\n"
+ "let c = '\\'';\n"
+ "// end\n",
+ 2,
+ 2,
+ 0,
+ ),
+ "SQL": (
+ "-- query\n"
+ "SELECT '-- not a comment' FROM t; -- but this is\n"
+ "/* a\n"
+ " block */\n"
+ "\n",
+ 1,
+ 3,
+ 1,
+ ),
+}
+
+
+@pytest.mark.parametrize("name", sorted(FIXTURES))
+def test_fixture_counts_equal_the_hand_count(name):
+ source, code, comment, blank = FIXTURES[name]
+ assert count_loc.classify_text(source, LANGUAGE[name]) == (code, comment, blank)
+
+
+def test_every_language_has_a_fixture():
+ assert set(FIXTURES) == set(LANGUAGE)
+
+
+class TestClassify:
+ def test_empty_text_has_no_lines(self):
+ assert count_loc.classify_text("", LANGUAGE["Python"]) == (0, 0, 0)
+
+ def test_windows_line_endings_are_one_line_each(self):
+ assert count_loc.classify_text("a = 1\r\n# c\r\n\r\n", LANGUAGE["Python"]) == (
+ 1,
+ 1,
+ 1,
+ )
+
+ def test_blank_line_inside_a_block_comment_is_blank(self):
+ assert count_loc.classify_text("/*\n\n*/\n", LANGUAGE["C"]) == (0, 2, 1)
+
+ def test_code_after_a_closing_block_comment_is_code(self):
+ assert count_loc.classify_text("/* a\n b */ x();\n", LANGUAGE["C"]) == (1, 1, 0)
+
+ def test_unterminated_string_ends_at_end_of_line(self):
+ assert count_loc.classify_text('s = "open // x\n// c\n', LANGUAGE["C"]) == (
+ 1,
+ 1,
+ 0,
+ )
+
+ def test_unterminated_backtick_string_continues(self):
+ assert count_loc.classify_text("a = `x\n// y\n", LANGUAGE["JavaScript"]) == (
+ 2,
+ 0,
+ 0,
+ )
+
+ def test_escaped_quote_does_not_end_the_string(self):
+ assert count_loc.classify_text('s = "a \\" // b";\n', LANGUAGE["C"]) == (
+ 1,
+ 0,
+ 0,
+ )
+
+
+class TestPatterns:
+ def test_star_stops_at_a_slash_and_double_star_does_not(self):
+ excludes = count_loc.Excludes(["src/*.py", "docs/**"])
+ assert excludes.matches("a.py", "src/a.py")
+ assert not excludes.matches("a.py", "src/sub/a.py")
+ assert excludes.matches("x.py", "docs/a/b/x.py")
+
+ def test_name_pattern_matches_anywhere(self):
+ excludes = count_loc.Excludes(["gen_*", "?.py"])
+ assert excludes.matches("gen_a.py", "deep/er/gen_a.py")
+ assert excludes.matches("a.py", "x/a.py")
+ assert not excludes.matches("ab.py", "x/ab.py")
+
+ def test_empty_patterns_are_ignored(self):
+ assert not count_loc.Excludes(["", " / "]).matches("a.py", "a.py")
+
+
+def write(root: Path, name: str, content: str | bytes) -> None:
+ path = root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if isinstance(content, bytes):
+ path.write_bytes(content)
+ else:
+ path.write_text(content, encoding="utf-8")
+
+
+class TestCount:
+ def test_counts_files_and_lines_per_language(self, tmp_path):
+ write(tmp_path, "a.py", "x = 1\n# c\n\n")
+ write(tmp_path, "pkg/b.py", "y = 2\n")
+ write(tmp_path, "run.sh", "echo hi\n")
+ write(tmp_path, "README.md", "# not code\n")
+ result = count_loc.count(tmp_path)
+ assert result == {
+ "files": 3,
+ "lines": 3,
+ "comment": 1,
+ "blank": 1,
+ "languages": {
+ "Python": {"files": 2, "lines": 2, "comment": 1, "blank": 1},
+ "Shell": {"files": 1, "lines": 1, "comment": 0, "blank": 0},
+ },
+ }
+
+ def test_default_excludes_hidden_vendored_and_shared_script_directories(
+ self, tmp_path
+ ):
+ write(tmp_path, "keep.py", "a = 1\n")
+ for directory in (
+ ".git",
+ ".venv",
+ "node_modules",
+ "vendor",
+ "shared-scripts",
+ "build",
+ ):
+ write(tmp_path, f"{directory}/skip.py", "b = 2\n")
+ assert count_loc.count(tmp_path)["files"] == 1
+
+ def test_exclude_patterns_add_to_the_defaults(self, tmp_path):
+ write(tmp_path, "keep.py", "a = 1\n")
+ write(tmp_path, "gen/out.py", "b = 2\n")
+ write(tmp_path, "src/gen_x.py", "c = 3\n")
+ write(tmp_path, "src/keep2.py", "d = 4\n")
+ result = count_loc.count(tmp_path, ["gen", "gen_*"])
+ assert result["files"] == 2
+
+ def test_binary_file_with_a_code_extension_is_not_counted(self, tmp_path):
+ write(tmp_path, "blob.py", b"a = 1\n\x00\x01")
+ assert count_loc.count(tmp_path)["files"] == 0
+
+ def test_invalid_utf8_and_a_byte_order_mark_are_read(self, tmp_path):
+ write(tmp_path, "a.py", b"\xef\xbb\xbfx = 1\n\xff\xfe = 2\n")
+ assert count_loc.count(tmp_path)["lines"] == 2
+
+ def test_empty_code_file_is_a_file_with_no_lines(self, tmp_path):
+ write(tmp_path, "empty.py", "")
+ result = count_loc.count(tmp_path)
+ assert (result["files"], result["lines"]) == (1, 0)
+
+ def test_directory_without_code_files_counts_zero(self, tmp_path):
+ write(tmp_path, "notes.txt", "hello\n")
+ result = count_loc.count(tmp_path)
+ assert result == {
+ "files": 0,
+ "lines": 0,
+ "comment": 0,
+ "blank": 0,
+ "languages": {},
+ }
+
+ def test_symbolic_link_is_not_followed(self, tmp_path):
+ real = tmp_path / "real"
+ write(real, "a.py", "x = 1\n")
+ link = tmp_path / "link.py"
+ try:
+ link.symlink_to(real / "a.py")
+ except (OSError, NotImplementedError):
+ pytest.skip("symbolic links are not available here")
+ assert count_loc.count(real)["files"] == 1
+ assert count_loc.count(tmp_path)["files"] == 1 # real/a.py only, not link.py
+
+
+class TestMain:
+ def test_prints_json_to_standard_output(self, tmp_path, capsys):
+ write(tmp_path, "a.py", "x = 1\n")
+ assert count_loc.main(["--directory", str(tmp_path)]) == 0
+ out = json.loads(capsys.readouterr().out)
+ assert out["files"] == 1 and out["lines"] == 1
+
+ def test_writes_the_output_file_with_lf_endings(self, tmp_path):
+ write(tmp_path, "src/a.py", "x = 1\n")
+ target = tmp_path / "result.json"
+ assert (
+ count_loc.main(
+ ["--directory", str(tmp_path / "src"), "--output", str(target)]
+ )
+ == 0
+ )
+ data = target.read_bytes()
+ assert b"\r" not in data and json.loads(data)["files"] == 1
+
+ def test_exclude_option_is_a_comma_list(self, tmp_path, capsys):
+ write(tmp_path, "a.py", "x = 1\n")
+ write(tmp_path, "b.py", "y = 1\n")
+ write(tmp_path, "c.py", "z = 1\n")
+ assert (
+ count_loc.main(["--directory", str(tmp_path), "--exclude", "a.py, b.py"])
+ == 0
+ )
+ assert json.loads(capsys.readouterr().out)["files"] == 1
+
+ def test_missing_directory_is_a_configuration_error(self, tmp_path, capsys):
+ assert count_loc.main(["--directory", str(tmp_path / "nope")]) == 1
+ assert "::error::[configuration]" in capsys.readouterr().out
diff --git a/tools/badges.py b/tools/badges.py
new file mode 100644
index 0000000..1c6ebf1
--- /dev/null
+++ b/tools/badges.py
@@ -0,0 +1,226 @@
+"""Read a Cobertura coverage report and write the code metrics badges (ADR-0010).
+
+Usage:
+ python tools/badges.py --metrics code-metrics.json --output-dir DIR
+ [--coverage-report coverage.xml] [--coverage-warn 60] [--coverage-good 80]
+
+Writes `code-files.svg`, `code-lines.svg`, `code-metrics.json` and, when a report
+is given, `coverage.svg` into DIR. The same input gives the same bytes.
+
+Exit codes: 0 written, 1 a configuration problem (printed as
+`::error::[configuration] ...`, nothing is written).
+"""
+
+import argparse
+import json
+import sys
+import xml.etree.ElementTree as ElementTree
+from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
+from pathlib import Path
+
+BLUE = "#007ec6"
+RED = "#e05d44"
+YELLOW = "#dfb317"
+GREEN = "#44cc11"
+LABEL_COLOR = "#555555"
+CHAR_WIDTH = 6
+PADDING = 10
+HEIGHT = 20
+
+
+class ConfigurationError(Exception):
+ """An input is invalid; the message is printed as a `[configuration]` failure."""
+
+
+def read_coverage(path: Path) -> int:
+ """Return the line coverage of a Cobertura XML report as a whole percent, half up."""
+ if not path.is_file():
+ raise ConfigurationError(f"coverage-report '{path}' does not exist.")
+ data = path.read_bytes()
+ if b" str:
+ """Return red below warn, yellow from warn to below good, green from good."""
+ if percent < warn:
+ return RED
+ if percent < good:
+ return YELLOW
+ return GREEN
+
+
+def _luminance(color: str) -> float:
+ channels = [int(color[i : i + 2], 16) / 255 for i in (1, 3, 5)]
+ linear = [
+ c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels
+ ]
+ return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
+
+
+def _text_color(background: str) -> str:
+ """Black or white, whichever has the higher contrast on the background."""
+ lum = _luminance(background)
+ return "#ffffff" if (1.05 / (lum + 0.05)) >= ((lum + 0.05) / 0.05) else "#000000"
+
+
+def _escape(text: str) -> str:
+ return (
+ text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ )
+
+
+def render(label: str, value: str, color: str) -> str:
+ """Return a flat two-part SVG badge; the width follows the number of characters."""
+ label_width = CHAR_WIDTH * len(label) + PADDING
+ value_width = CHAR_WIDTH * len(value) + PADDING
+ total = label_width + value_width
+ text = _escape(f"{label}: {value}")
+ label_x = label_width / 2
+ value_x = label_width + value_width / 2
+ return (
+ f'\n"
+ )
+
+
+def _threshold(name: str, text: str) -> float:
+ try:
+ value = float(text)
+ except ValueError as error:
+ raise ConfigurationError(
+ f"{name} '{text}' is not a number from 0 to 100."
+ ) from error
+ if not 0 <= value <= 100:
+ raise ConfigurationError(f"{name} '{text}' is not a number from 0 to 100.")
+ return value
+
+
+def write_badges(
+ file_count: int,
+ line_count: int,
+ metrics_json: str,
+ output_dir: Path,
+ coverage: int | None,
+ warn: float,
+ good: float,
+) -> list[Path]:
+ """Write the badge files and the metrics JSON; return the paths in a fixed order."""
+ output_dir.mkdir(parents=True, exist_ok=True)
+ files = {
+ "code-files.svg": render("code files", f"{file_count:,}", BLUE),
+ "code-lines.svg": render("lines of code", f"{line_count:,}", BLUE),
+ }
+ if coverage is not None:
+ files["coverage.svg"] = render(
+ "coverage", f"{coverage}%", coverage_color(coverage, warn, good)
+ )
+ files["code-metrics.json"] = metrics_json
+ paths = []
+ for name, content in files.items():
+ path = output_dir / name
+ path.write_text(content, encoding="utf-8", newline="\n")
+ paths.append(path)
+ return paths
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--metrics", required=True, help="JSON written by count_loc.py")
+ parser.add_argument("--output-dir", required=True)
+ parser.add_argument("--coverage-report", default="")
+ parser.add_argument("--coverage-warn", default="60")
+ parser.add_argument("--coverage-good", default="80")
+ args = parser.parse_args(argv)
+ try:
+ warn = _threshold("coverage-warn", args.coverage_warn)
+ good = _threshold("coverage-good", args.coverage_good)
+ if warn > good:
+ raise ConfigurationError(
+ f"coverage-warn {args.coverage_warn} is above coverage-good {args.coverage_good}."
+ )
+ coverage = (
+ read_coverage(Path(args.coverage_report)) if args.coverage_report else None
+ )
+ metrics = json.loads(Path(args.metrics).read_text(encoding="utf-8"))
+ file_count, line_count = int(metrics["files"]), int(metrics["lines"])
+ except ConfigurationError as error:
+ print(f"::error::[configuration] {error}")
+ return 1
+ except (OSError, ValueError, KeyError, TypeError) as error:
+ print(
+ f"::error::[configuration] metrics file '{args.metrics}' cannot be read: {error}"
+ )
+ return 1
+ if coverage is None:
+ print("::notice::No coverage-report given; no coverage badge is written.")
+ metrics_json = json.dumps(metrics, indent=2, sort_keys=True) + "\n"
+ written = write_badges(
+ file_count,
+ line_count,
+ metrics_json,
+ Path(args.output_dir),
+ coverage,
+ warn,
+ good,
+ )
+ for path in written:
+ print(f"wrote {path}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/count_loc.py b/tools/count_loc.py
new file mode 100644
index 0000000..4472900
--- /dev/null
+++ b/tools/count_loc.py
@@ -0,0 +1,375 @@
+"""Count the code files and the lines of code of a directory (ADR-0010).
+
+A code file is a text file whose extension is in LANGUAGES. Every physical line
+of it is exactly one of blank, comment or code; the lines of code are the code
+lines only. The result is JSON:
+
+ {"files": 3, "lines": 120, "comment": 14, "blank": 22,
+ "languages": {"Python": {"files": 2, "lines": 100, "comment": 10, "blank": 20}}}
+
+Usage:
+ python tools/count_loc.py [--directory DIR] [--exclude PATTERNS] [--output FILE]
+
+Exit codes: 0 counted, 1 the directory is not a directory (printed as
+`::error::[configuration] ...`).
+"""
+
+import argparse
+import json
+import os
+import re
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+DEFAULT_EXCLUDED_DIRS = frozenset(
+ {
+ "node_modules",
+ "vendor",
+ "third_party",
+ "dist",
+ "build",
+ "out",
+ "bin",
+ "obj",
+ "target",
+ "venv",
+ "__pycache__",
+ "shared-scripts",
+ }
+)
+
+_C_STYLE_CHAR = re.compile(r"'(?:\\.[^']*|[^'\\])'")
+
+
+@dataclass(frozen=True)
+class Language:
+ """The comment and string rules of one language."""
+
+ name: str
+ extensions: tuple[str, ...]
+ line_comment: str | None = None
+ block: tuple[str, str] | None = None
+ nested_block: bool = False
+ hash_at_word_start: bool = False
+ triple_quotes: bool = False
+ backtick_strings: bool = False
+ single_quote: str = (
+ "string" # "string", "char" (a literal of one character) or "none"
+ )
+ backslash_escapes: bool = True
+ escapes_in_single_quotes: bool = True
+
+
+def _c_family(name: str, *extensions: str, single_quote: str = "char") -> Language:
+ return Language(
+ name,
+ extensions,
+ line_comment="//",
+ block=("/*", "*/"),
+ single_quote=single_quote,
+ )
+
+
+LANGUAGES: tuple[Language, ...] = (
+ Language("Python", (".py",), line_comment="#", triple_quotes=True),
+ Language(
+ "Shell",
+ (".sh", ".bash"),
+ line_comment="#",
+ hash_at_word_start=True,
+ escapes_in_single_quotes=False,
+ ),
+ Language(
+ "PowerShell",
+ (".ps1", ".psm1"),
+ line_comment="#",
+ block=("<#", "#>"),
+ hash_at_word_start=True,
+ backslash_escapes=False,
+ ),
+ _c_family("C", ".c", ".h"),
+ _c_family("C++", ".cc", ".cpp", ".cxx", ".hh", ".hpp"),
+ _c_family("C#", ".cs"),
+ _c_family("Java", ".java"),
+ Language(
+ "JavaScript",
+ (".js", ".mjs", ".jsx"),
+ line_comment="//",
+ block=("/*", "*/"),
+ backtick_strings=True,
+ ),
+ Language(
+ "TypeScript",
+ (".ts", ".tsx"),
+ line_comment="//",
+ block=("/*", "*/"),
+ backtick_strings=True,
+ ),
+ Language(
+ "Go",
+ (".go",),
+ line_comment="//",
+ block=("/*", "*/"),
+ backtick_strings=True,
+ single_quote="char",
+ ),
+ Language(
+ "Rust",
+ (".rs",),
+ line_comment="//",
+ block=("/*", "*/"),
+ nested_block=True,
+ single_quote="char",
+ ),
+ Language(
+ "SQL",
+ (".sql",),
+ line_comment="--",
+ block=("/*", "*/"),
+ backslash_escapes=False,
+ ),
+)
+
+_BY_EXTENSION = {ext: language for language in LANGUAGES for ext in language.extensions}
+_WORD_START = " \t;|&("
+
+
+def language_of(path: str) -> Language | None:
+ """Return the language of a file name by its extension, or None."""
+ return _BY_EXTENSION.get(os.path.splitext(path)[1].lower())
+
+
+@dataclass
+class _State:
+ block_depth: int = 0
+ triple: str | None = None
+ backtick: bool = False
+
+
+def _skip_string(line: str, start: int, quote: str, escapes: bool) -> int:
+ """Return the index after the string that opens at start; the end of line if unclosed."""
+ i = start + 1
+ while i < len(line):
+ char = line[i]
+ if escapes and char == "\\":
+ i += 2
+ continue
+ if char == quote:
+ return i + 1
+ i += 1
+ return len(line)
+
+
+def _scan_line(line: str, language: Language, state: _State) -> tuple[bool, bool]:
+ """Scan one line; return (has_code, has_comment) and update the state."""
+ has_code = False
+ has_comment = False
+ i = 0
+ size = len(line)
+ block = language.block
+ while i < size:
+ if state.block_depth:
+ assert block is not None
+ has_comment = True
+ end = line.find(block[1], i)
+ nested = line.find(block[0], i) if language.nested_block else -1
+ if nested != -1 and (end == -1 or nested < end):
+ state.block_depth += 1
+ i = nested + len(block[0])
+ elif end == -1:
+ return has_code, has_comment
+ else:
+ state.block_depth -= 1
+ i = end + len(block[1])
+ continue
+ if state.triple is not None:
+ has_code = True
+ end = line.find(state.triple, i)
+ if end == -1:
+ return has_code, has_comment
+ i = end + len(state.triple)
+ state.triple = None
+ continue
+ if state.backtick:
+ has_code = True
+ end = line.find("`", i)
+ if end == -1:
+ return has_code, has_comment
+ i = end + 1
+ state.backtick = False
+ continue
+ char = line[i]
+ if char in " \t\r\f\v":
+ i += 1
+ continue
+ if block is not None and line.startswith(block[0], i):
+ state.block_depth = 1
+ has_comment = True
+ i += len(block[0])
+ continue
+ comment = language.line_comment
+ if comment is not None and line.startswith(comment, i):
+ word_start = i == 0 or line[i - 1] in _WORD_START
+ if word_start or not language.hash_at_word_start:
+ return has_code, True
+ if language.triple_quotes and (
+ line.startswith('"""', i) or line.startswith("'''", i)
+ ):
+ state.triple = line[i : i + 3]
+ has_code = True
+ i += 3
+ continue
+ if char == '"':
+ has_code = True
+ i = _skip_string(line, i, '"', language.backslash_escapes)
+ continue
+ if char == "'" and language.single_quote == "string":
+ has_code = True
+ escapes = language.backslash_escapes and language.escapes_in_single_quotes
+ i = _skip_string(line, i, "'", escapes)
+ continue
+ if char == "'" and language.single_quote == "char":
+ has_code = True
+ literal = _C_STYLE_CHAR.match(line, i)
+ i = literal.end() if literal else i + 1
+ continue
+ if char == "`" and language.backtick_strings:
+ has_code = True
+ end = line.find("`", i + 1)
+ if end == -1:
+ state.backtick = True
+ return has_code, has_comment
+ i = end + 1
+ continue
+ has_code = True
+ i += 1
+ return has_code, has_comment
+
+
+def classify_text(text: str, language: Language) -> tuple[int, int, int]:
+ """Return (code, comment, blank) line counts of the text of a file."""
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
+ if lines and lines[-1] == "":
+ lines.pop()
+ state = _State()
+ code = comment = blank = 0
+ for line in lines:
+ has_code, _ = _scan_line(line, language, state)
+ if line.strip() == "":
+ blank += 1
+ elif has_code:
+ code += 1
+ else:
+ comment += 1
+ return code, comment, blank
+
+
+def _pattern_regex(pattern: str) -> re.Pattern[str]:
+ """Translate an exclude pattern: `*` stops at `/`, `**` crosses it, `?` is one character."""
+ out = []
+ i = 0
+ while i < len(pattern):
+ char = pattern[i]
+ if pattern.startswith("**", i):
+ out.append(".*")
+ i += 2
+ continue
+ out.append(
+ "[^/]*" if char == "*" else "[^/]" if char == "?" else re.escape(char)
+ )
+ i += 1
+ return re.compile("".join(out) + r"\Z")
+
+
+class Excludes:
+ """The exclude patterns of the caller; a pattern without `/` matches a name anywhere."""
+
+ def __init__(self, patterns: list[str]) -> None:
+ self._names: list[re.Pattern[str]] = []
+ self._paths: list[re.Pattern[str]] = []
+ for pattern in patterns:
+ pattern = pattern.strip().strip("/")
+ if not pattern:
+ continue
+ target = self._paths if "/" in pattern else self._names
+ target.append(_pattern_regex(pattern))
+
+ def matches(self, name: str, relative: str) -> bool:
+ return any(rx.match(name) for rx in self._names) or any(
+ rx.match(relative) for rx in self._paths
+ )
+
+
+def _is_text(data: bytes) -> bool:
+ return b"\x00" not in data
+
+
+def count(directory: Path, patterns: list[str] | None = None) -> dict[str, object]:
+ """Count the code files under directory; see the module docstring for the result."""
+ excludes = Excludes(patterns or [])
+ totals = {"files": 0, "lines": 0, "comment": 0, "blank": 0}
+ per_language: dict[str, dict[str, int]] = {}
+ root = str(directory)
+ for current, dirs, files in os.walk(root, followlinks=False):
+ relative_dir = os.path.relpath(current, root).replace(os.sep, "/")
+ prefix = "" if relative_dir == "." else relative_dir + "/"
+ dirs[:] = sorted(
+ name
+ for name in dirs
+ if not name.startswith(".")
+ and name not in DEFAULT_EXCLUDED_DIRS
+ and not excludes.matches(name, prefix + name)
+ )
+ for name in sorted(files):
+ full = os.path.join(current, name)
+ language = language_of(name)
+ if (
+ language is None
+ or os.path.islink(full)
+ or excludes.matches(name, prefix + name)
+ ):
+ continue
+ data = Path(full).read_bytes()
+ if not _is_text(data):
+ continue
+ text = data.decode("utf-8", errors="replace").lstrip("")
+ code, comment, blank = classify_text(text, language)
+ entry = per_language.setdefault(
+ language.name, {"files": 0, "lines": 0, "comment": 0, "blank": 0}
+ )
+ for target in (entry, totals):
+ target["files"] += 1
+ target["lines"] += code
+ target["comment"] += comment
+ target["blank"] += blank
+ return {**totals, "languages": dict(sorted(per_language.items()))}
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument(
+ "--directory", default=".", help="directory to count (default .)"
+ )
+ parser.add_argument("--exclude", default="", help="comma list of exclude patterns")
+ parser.add_argument(
+ "--output", help="write the JSON here instead of standard output"
+ )
+ args = parser.parse_args(argv)
+ directory = Path(args.directory)
+ if not directory.is_dir():
+ print(
+ f"::error::[configuration] working-directory '{args.directory}' does not exist."
+ )
+ return 1
+ result = count(directory, [p for p in args.exclude.split(",") if p.strip()])
+ text = json.dumps(result, indent=2, sort_keys=True) + "\n"
+ if args.output:
+ Path(args.output).write_text(text, encoding="utf-8", newline="\n")
+ else:
+ sys.stdout.write(text)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())