A personalized, lightweight toolchain for tracking project metrics across both executable code and design documentation.
moosecount: A fast, C++17 command-line tool that counts source in eleven languages, each parsed by its own rules.moosemetrics: A companion tool for extracting structural metrics from Markdown, Text, and PlantUML design files. Both binaries are built from one library, so they share the same ignore rules and the same command line.
This project uses googletest for its unit tests, vendored as a git submodule. Clone with submodules, or initialize them afterwards:
git clone --recurse-submodules <url>
# or, in an existing clone
git submodule update --init --recursiveThis project utilizes CMake wrapped in a standard Makefile. You can install these tools directly to your user's local binary folder (~/.local/bin) so they act like native system commands across all your projects.
make installTo remove the tools from your system:
make uninstallBecause these tools are installed to your user directory (~/.local/bin) rather than the root system directory, you need to ensure your terminal knows where to find them.
For macOS (Default Zsh): Run the following commands to add the path to your profile and reload it:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
source ~/.zshrcFor WSL / Ubuntu (Default Bash): Run the following commands to add the path to your profile and reload it:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc(Note: You only need to do this once. After this, moosecount and moosemetrics will be available in every new terminal window you open).
Accurately tracks and separates metrics into Code Lines, Format Lines (isolated brackets { or }), Comment Lines, and Blank Lines.
moosecount [options] <path1> <path2> ...(If no path is provided, it searches the current directory).
Options:
--exclude <rule>: Skips a file or folder during traversal. Accepts the same rule syntax as--ignore-file(see below), so--exclude "*build*"skipsbuild,prebuild_out, andbuild_x64alike. Can be used multiple times.--ignore-file <filename>: Reads a file (like.gitignore) and applies the rules inside it. Comment lines (#) are handled for you. Reads exactly the one file you name.--gitignore: Discovers and honors every.gitignorein the tree as it walks, the way git itself does. Rules in a nested.gitignoreapply only to that folder and below, and anchored rules inside it are relative to that folder — so a submodule's.gitignoreno longer leaks into its siblings. Combines with--excludeand--ignore-file.--ext <extension>: Adds an extension to the scanned set (the leading dot is optional, so--ext pyand--ext .pyare equivalent). Can be used multiple times.--no-defaults: Clears the built-in extension list so that only extensions added with--extare scanned.--sort: Orders the per-file listing by line count, largest first. Without it, files are listed by path. Either way the order is deterministic, so two runs can be diffed against each other.--json: Emits the report as JSON instead of a table. See JSON output.-h,--help: Prints the full option list and rule syntax, then exits.-v,--version: Prints the version, then exits.
The same syntax is shared by --exclude, --ignore-file, and --gitignore:
| Rule | Matches |
|---|---|
build |
anything named build, file or folder, at any depth |
build/ |
only folders named build, at any depth |
*.gen.cpp |
any file whose name ends in .gen.cpp |
*build* |
any name containing build |
build_? |
build_1, build_x, … |
/build |
only build at the root of the searched path |
./build |
build inside your current directory |
../app/build |
that folder, resolved from your current directory |
src/generated |
only that exact path, relative to the searched path |
**/temp_out |
a temp_out folder at any depth, including the top level |
!keep_me.cpp |
puts back something an earlier rule excluded |
A rule containing a / is anchored to the path you searched rather than matching a bare name, which is how gitignore behaves. In those anchored rules * and ? stop at a / boundary, while ** spans directories. A trailing / limits a rule to folders.
Rules that start with ./ or ../ are the exception: those are read as paths relative to your current directory, not to the path being searched, because that is what they mean everywhere else in a shell. So counting a project that lives above you works the way you would type it:
moosecount --gitignore --exclude ../../other_app/junk ../../other_appIf such a rule resolves to somewhere outside every path you asked to count, it cannot exclude anything, and moosecount tells you rather than leaving it to be discovered in the numbers:
Warning: --exclude "../../junk/" is outside the paths being searched
A bare src/generated keeps its gitignore meaning — relative to each searched path — so moosecount --exclude src/gen dirA dirB still means "src/gen inside each of them".
A rule starting with a single / always keeps its gitignore meaning — the root of the searched path — even when it looks like a filesystem path. To exclude by absolute path, use a bare name, or spell it relative to where you are standing with ./ or ../.
Order matters. Within one rule set the last rule that matches wins, which is what lets a ! rule override an earlier exclusion. Between rule sets, a .gitignore deeper in the tree overrides the one above it, and rules given on the command line override both.
As in git, a ! rule cannot rescue a file whose folder was already excluded — the folder is never walked into, so nothing inside it can be put back.
If an --exclude rule you typed never matches anything, moosecount says so on stderr rather than leaving you to wonder why your counts look wrong:
Warning: --exclude "tetss" matched nothing
Rules read from an ignore file stay quiet, since a shared .gitignore listing folders that do not exist in the tree you are counting is perfectly normal.
Each language is counted by its own rules — its comment tokens, its string delimiters, and whether block comments nest — rather than by pretending everything is C.
| Language | Extensions |
|---|---|
| C/C++ | .c .cc .cpp .cxx .h .hh .hpp .hxx |
| Objective-C | .m .mm |
| Java | .java |
| C# | .cs |
| JavaScript/TypeScript | .js .jsx .mjs .ts .tsx |
| Kotlin | .kt .kts |
| Swift | .swift |
| Go | .go |
| Rust | .rs |
| Python | .py .pyw |
| Shell | .sh .bash .zsh |
Every extension above is scanned by default. Files named directly on the command line are always counted, whatever their extension.
An extension added with --ext that no language claims is counted blank
versus non-blank, with a warning saying so — rather than being parsed as C and
quietly miscounted.
Format lines depend on the language. They are lines holding nothing but
structural characters, which for the C family means { and }. Python
expresses structure through indentation, so Python files report no format
lines at all. That is a property of the language, not a gap in the counting.
Known limitations, both needing more than a state machine:
- JavaScript regular expression literals. A
/that starts a regex is indistinguishable from division without parsing, so a regex containing a quote will confuse string tracking. - Shell heredocs.
Example Run & Output:
moosecount --exclude build --exclude scratch .145 ./src/main.cpp
82 ./src/logger.cpp
24 ./include/logger.hpp
TOTALS...
Lines of Code = 251
File Count = 3
Code Lines = 220
Format Lines = 31
Comment Lines = 45
Blank Lines = 60
Total Lines = 356
By Language
C/C++ = 227
Python = 24
(The By Language breakdown is only printed when more than one language was matched. Both it and the per-file column report Code Lines + Format Lines.)
Tracks architectural complexity and task completion. It extracts Word counts, Open vs. Completed tasks (- [ ] vs - [x]), and PlantUML entities/relationships.
moosemetrics [options] <path1> <path2> ...Options: the same as moosecount — --exclude, --ignore-file, --gitignore, --ext, --no-defaults, --sort, --json, --help, --version — taking the identical ignore rule syntax. The two tools share one implementation of that, so a rule means the same thing to both.
build, bin, .git and .vscode are excluded by default.
Documents scanned by default: .md and .txt as Markdown, .puml, .pu and .wsd as PlantUML.
Example Run & Output:
moosemetrics --exclude scratch .File | Metrics
--------------------------------------------------------------------------------
./design/architecture.puml | Entities: 12 | Relationships: 18
./devlog/todo.md | Words: 120 | Tasks: 10/12
================================================================================
TOTALS
================================================================================
Markdown & Text Files: 1
- Lines : 45
- Words : 120
- Headers : 2
- Open Tasks : 2
- Completed Tasks: 10
PlantUML Files: 1
- Lines : 45
- UML Entities : 12
- Relationships : 18
Both tools take --json, emitting the same envelope so one consumer can read
either:
{
"schemaVersion": 1,
"tool": "moosecount",
"version": "0.4.0",
"totals": { "files": 17, "linesOfCode": 496, "code": 335, "format": 161,
"comment": 41, "blank": 70, "total": 605 },
"languages": [ { "name": "C/C++", "files": 12, "linesOfCode": 420, "...": 0 } ],
"files": [ { "path": "src/parser.cpp", "language": "C/C++", "...": 0 } ],
"warnings": [ { "kind": "unmatchedRule", "subject": "tetss",
"message": "--exclude \"tetss\" matched nothing" } ]
}moosemetrics substitutes kinds for languages and reports document fields —
words, headers, openTasks, completedTasks, umlEntities,
relationships.
Warnings appear both on stderr and in the document, so a job capturing only
stdout still sees them. schemaVersion is there so a consumer can tell when
the shape changes.
Two suites, both run by make test:
- Unit tests (
tests/unit/, googletest) link the library directly and cover line classification per language, the ignore rule engine, and the wildcard matcher. - Integration tests (
tests/run_tests.py) run the compiled binary against the fixture trees intests/data*/, covering the CLI conventions and the ignore behavior end to end.
make test