| File: | blib/lib/App/GHGen/Detector.pm |
| Coverage: | 82.6% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::GHGen::Detector; | |||||
| 2 | ||||||
| 3 | 7 7 | 121958 8 | use v5.36; | |||
| 4 | 7 7 7 | 8 5 42 | use strict; | |||
| 5 | 7 7 7 | 6 10 106 | use warnings; | |||
| 6 | ||||||
| 7 | 7 7 7 | 8 5 111 | use Path::Tiny; | |||
| 8 | ||||||
| 9 | 7 7 7 | 9 5 3931 | use Exporter 'import'; | |||
| 10 | our @EXPORT_OK = qw( | |||||
| 11 | detect_project_type | |||||
| 12 | get_project_indicators | |||||
| 13 | ); | |||||
| 14 | ||||||
| 15 | our $VERSION = '0.10'; | |||||
| 16 | ||||||
| 17 - 87 | =head1 NAME
App::GHGen::Detector - Detect project type from repository contents
=head1 SYNOPSIS
use App::GHGen::Detector qw(detect_project_type);
my $type = detect_project_type();
# Returns: 'perl', 'node', 'python', etc.
=head1 FUNCTIONS
=head2 detect_project_type()
Detect the project type by examining indicator files in the current directory.
=head3 Purpose
Score the current working directory against ten known project types
(perl, node, python, rust, go, ruby, php, java, cpp, docker) and return
the highest-scoring type.
=head3 Arguments
None.
=head3 Returns
In scalar context: the type string (e.g. C<'perl'>) of the best match, or
C<undef> when no type scores above zero.
In list context: a list of C<{ type => Str, score => Int, indicators => [] }>
hash references sorted by descending score.
=head3 Side Effects
Reads the filesystem; results depend on the current working directory.
=head3 Usage Example
use App::GHGen::Detector qw(detect_project_type);
my $type = detect_project_type();
say $type // 'unknown';
=head3 API SPECIFICATION
=head4 Input
# No parameters.
=head4 Output (scalar context)
{ type => 'scalar' } # may be undef
=head4 Output (list context)
{ type => 'array', element => { type => 'hashref' } }
=head3 FORMAL SPECIFICATION
detect_project_type : â â¤* ⪠{ ⥠} | seq Detection
Detection â { type: ProjectType, score: â, indicators: seq â¤* }
detections â [ { type: t, score: score(t) } ⣠t â Types, score(t) > 0 ]
ranked â sort desc-score detections
scalar context: ranked = â
â ⥠| ranked â â
â ranked[0].type
list context: ranked
=cut | |||||
| 88 | ||||||
| 89 | 18 18 | 23067 12 | sub detect_project_type() { | |||
| 90 | 18 | 16 | my @detections; | |||
| 91 | ||||||
| 92 | # Check for each project type | |||||
| 93 | 18 | 22 | for my $type (qw(perl node python rust go ruby php java cpp docker)) { | |||
| 94 | 180 | 131 | my $detector = "_detect_$type"; | |||
| 95 | 180 | 284 | my $score = __PACKAGE__->can($detector)->(); | |||
| 96 | 180 | 157 | push @detections, { type => $type, score => $score, indicators => [] } if $score > 0; | |||
| 97 | } | |||||
| 98 | ||||||
| 99 | # Sort by score (highest first) | |||||
| 100 | 18 5 | 23 9 | @detections = sort { $b->{score} <=> $a->{score} } @detections; | |||
| 101 | ||||||
| 102 | # `return;` returns () in list context and undef in scalar context â correct for both. | |||||
| 103 | # `return undef` would return (undef) in list context, i.e. a one-element list. | |||||
| 104 | 18 | 23 | return unless @detections; | |||
| 105 | 13 | 28 | return wantarray ? @detections : $detections[0]->{type}; | |||
| 106 | } | |||||
| 107 | ||||||
| 108 - 172 | =head2 get_project_indicators($type)
Return the indicator file patterns associated with a given project type.
=head3 Purpose
Provide a human-readable or programmatic list of the files and patterns that
the detector uses to score each project type.
=head3 Arguments
=over 4
=item C<$type> (Str, optional)
A project type string such as C<'perl'>, C<'node'>, C<'python'>, etc.
When omitted or C<undef>, the full indicator table is returned.
=back
=head3 Returns
When C<$type> is given and known: an array reference of strings (file names /
glob patterns).
When C<$type> is C<undef> or omitted: a hash reference mapping every known
type to its indicator list.
When C<$type> is given but unknown: C<undef>.
=head3 Side Effects
None. Pure lookup function.
=head3 Usage Example
my $indicators = get_project_indicators('perl');
say for @$indicators;
my $all = get_project_indicators();
for my $type (sort keys %$all) {
say "$type: " . join(', ', @{$all->{$type}});
}
=head3 API SPECIFICATION
=head4 Input
{ type => { type => 'scalar', optional => 1 } }
=head4 Output
known type â { type => 'arrayref' }
all types â { type => 'hashref' }
unknown type â undef
=head3 FORMAL SPECIFICATION
get_project_indicators : â¤* ⪠{ ⥠} â seq â¤* ⪠(â¤* â seq â¤*) ⪠{ ⥠}
t given â§ t â KnownTypes â indicators[t] (ArrayRef)
t given â§ t â KnownTypes â ⥠(undef)
t absent â indicators (HashRef of all types)
=cut | |||||
| 173 | ||||||
| 174 | 12 12 12 | 9281 9 9 | sub get_project_indicators($type = undef) { | |||
| 175 | 12 | 77 | my %indicators = ( | |||
| 176 | perl => [ | |||||
| 177 | 'cpanfile', 'dist.ini', 'Makefile.PL', 'Build.PL', | |||||
| 178 | 'lib/*.pm', 't/*.t', 'META.json', 'META.yml' | |||||
| 179 | ], | |||||
| 180 | node => [ | |||||
| 181 | 'package.json', 'package-lock.json', 'yarn.lock', | |||||
| 182 | 'node_modules/', 'tsconfig.json', '.npmrc' | |||||
| 183 | ], | |||||
| 184 | python => [ | |||||
| 185 | 'requirements.txt', 'setup.py', 'pyproject.toml', | |||||
| 186 | 'Pipfile', 'poetry.lock', 'setup.cfg', 'tox.ini' | |||||
| 187 | ], | |||||
| 188 | rust => [ | |||||
| 189 | 'Cargo.toml', 'Cargo.lock', 'src/main.rs', | |||||
| 190 | 'src/lib.rs', 'rust-toolchain.toml' | |||||
| 191 | ], | |||||
| 192 | go => [ | |||||
| 193 | 'go.mod', 'go.sum', 'main.go', '*.go' | |||||
| 194 | ], | |||||
| 195 | ruby => [ | |||||
| 196 | 'Gemfile', 'Gemfile.lock', 'Rakefile', | |||||
| 197 | '.ruby-version', 'config.ru' | |||||
| 198 | ], | |||||
| 199 | php => [ | |||||
| 200 | 'composer.json', 'composer.lock', 'phpunit.xml', | |||||
| 201 | 'phpunit.xml.dist', 'src/', 'tests/' | |||||
| 202 | ], | |||||
| 203 | java => [ | |||||
| 204 | 'pom.xml', 'build.gradle', 'build.gradle.kts', | |||||
| 205 | 'gradlew', 'mvnw', 'src/main/java/' | |||||
| 206 | ], | |||||
| 207 | cpp => [ | |||||
| 208 | 'CMakeLists.txt', 'Makefile', 'configure.ac', | |||||
| 209 | '*.cpp', '*.hpp', '*.cc', '*.h' | |||||
| 210 | ], | |||||
| 211 | c => [ | |||||
| 212 | 'CMakeLists.txt', 'Makefile', 'configure.ac', | |||||
| 213 | '*.c', '*.h', | |||||
| 214 | ], | |||||
| 215 | docker => [ | |||||
| 216 | 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', | |||||
| 217 | '.dockerignore' | |||||
| 218 | ], | |||||
| 219 | ); | |||||
| 220 | ||||||
| 221 | 12 | 37 | return $type ? $indicators{$type} : \%indicators; | |||
| 222 | } | |||||
| 223 | ||||||
| 224 | # Detection functions - return a score (0 = not detected, higher = more confident) | |||||
| 225 | ||||||
| 226 | 24 24 | 5527 15 | sub _detect_perl() { | |||
| 227 | 24 | 16 | my $score = 0; | |||
| 228 | ||||||
| 229 | # Strong indicators | |||||
| 230 | 24 | 31 | $score += 10 if path('cpanfile')->exists; | |||
| 231 | 24 | 440 | $score += 10 if path('dist.ini')->exists; | |||
| 232 | 24 | 317 | $score += 8 if path('Makefile.PL')->exists; | |||
| 233 | 24 | 298 | $score += 8 if path('Build.PL')->exists; | |||
| 234 | ||||||
| 235 | # Medium indicators | |||||
| 236 | 24 | 292 | $score += 5 if path('META.json')->exists; | |||
| 237 | 24 | 297 | $score += 5 if path('META.yml')->exists; | |||
| 238 | ||||||
| 239 | # Weak indicators | |||||
| 240 | 24 | 295 | $score += 3 if path('lib')->exists && path('lib')->is_dir; | |||
| 241 | 24 | 441 | $score += 2 if path('t')->exists && path('t')->is_dir; | |||
| 242 | ||||||
| 243 | # Check for .pm files in lib/ | |||||
| 244 | 24 | 405 | if (path('lib')->exists) { | |||
| 245 | 11 | 124 | my @pm_files = path('lib')->children(qr/\.pm$/); | |||
| 246 | 11 | 483 | $score += 2 if @pm_files > 0; | |||
| 247 | } | |||||
| 248 | ||||||
| 249 | # Check for .t files in t/ | |||||
| 250 | 24 | 157 | if (path('t')->exists) { | |||
| 251 | 10 | 126 | my @t_files = path('t')->children(qr/\.t$/); | |||
| 252 | 10 | 1404 | $score += 1 if @t_files > 0; | |||
| 253 | } | |||||
| 254 | ||||||
| 255 | 24 | 189 | return $score; | |||
| 256 | } | |||||
| 257 | ||||||
| 258 | 21 21 | 4660 19 | sub _detect_node() { | |||
| 259 | 21 | 12 | my $score = 0; | |||
| 260 | ||||||
| 261 | # Strong indicators | |||||
| 262 | 21 | 26 | $score += 15 if path('package.json')->exists; | |||
| 263 | 21 | 265 | $score += 8 if path('package-lock.json')->exists; | |||
| 264 | 21 | 246 | $score += 8 if path('yarn.lock')->exists; | |||
| 265 | 21 | 271 | $score += 7 if path('pnpm-lock.yaml')->exists; | |||
| 266 | ||||||
| 267 | # Medium indicators | |||||
| 268 | 21 | 250 | $score += 5 if path('node_modules')->exists && path('node_modules')->is_dir; | |||
| 269 | 21 | 261 | $score += 4 if path('tsconfig.json')->exists; | |||
| 270 | 21 | 254 | $score += 3 if path('.npmrc')->exists; | |||
| 271 | ||||||
| 272 | # Weak indicators | |||||
| 273 | 21 | 245 | $score += 2 if path('src')->exists && path('src')->is_dir; | |||
| 274 | ||||||
| 275 | 21 | 244 | return $score; | |||
| 276 | } | |||||
| 277 | ||||||
| 278 | 22 22 | 5170 11 | sub _detect_python() { | |||
| 279 | 22 | 13 | my $score = 0; | |||
| 280 | ||||||
| 281 | # Strong indicators | |||||
| 282 | 22 | 27 | $score += 12 if path('requirements.txt')->exists; | |||
| 283 | 22 | 277 | $score += 12 if path('setup.py')->exists; | |||
| 284 | 22 | 255 | $score += 12 if path('pyproject.toml')->exists; | |||
| 285 | 22 | 257 | $score += 10 if path('Pipfile')->exists; | |||
| 286 | 22 | 250 | $score += 8 if path('poetry.lock')->exists; | |||
| 287 | ||||||
| 288 | # Medium indicators | |||||
| 289 | 22 | 250 | $score += 5 if path('setup.cfg')->exists; | |||
| 290 | 22 | 289 | $score += 4 if path('tox.ini')->exists; | |||
| 291 | 22 | 284 | $score += 3 if path('.python-version')->exists; | |||
| 292 | ||||||
| 293 | # Weak indicators | |||||
| 294 | 22 | 277 | $score += 2 if path('venv')->exists || path('.venv')->exists; | |||
| 295 | ||||||
| 296 | # Check for .py files | |||||
| 297 | 22 | 531 | my @py_files = path('.')->children(qr/\.py$/); | |||
| 298 | 22 | 852 | $score += 2 if @py_files > 0; | |||
| 299 | ||||||
| 300 | 22 | 21 | return $score; | |||
| 301 | } | |||||
| 302 | ||||||
| 303 | 19 19 | 1686 7 | sub _detect_rust() { | |||
| 304 | 19 | 15 | my $score = 0; | |||
| 305 | ||||||
| 306 | # Strong indicators | |||||
| 307 | 19 | 16 | $score += 15 if path('Cargo.toml')->exists; | |||
| 308 | 19 | 280 | $score += 10 if path('Cargo.lock')->exists; | |||
| 309 | ||||||
| 310 | # Medium indicators | |||||
| 311 | 19 | 220 | $score += 5 if path('src/main.rs')->exists; | |||
| 312 | 19 | 207 | $score += 5 if path('src/lib.rs')->exists; | |||
| 313 | 19 | 208 | $score += 3 if path('rust-toolchain.toml')->exists || path('rust-toolchain')->exists; | |||
| 314 | ||||||
| 315 | # Weak indicators | |||||
| 316 | 19 | 431 | $score += 2 if path('target')->exists && path('target')->is_dir; | |||
| 317 | ||||||
| 318 | 19 | 250 | return $score; | |||
| 319 | } | |||||
| 320 | ||||||
| 321 | 22 22 | 5957 17 | sub _detect_go() { | |||
| 322 | 22 | 15 | my $score = 0; | |||
| 323 | ||||||
| 324 | # Strong indicators | |||||
| 325 | 22 | 22 | $score += 15 if path('go.mod')->exists; | |||
| 326 | 22 | 273 | $score += 10 if path('go.sum')->exists; | |||
| 327 | ||||||
| 328 | # Medium indicators | |||||
| 329 | 22 | 260 | $score += 5 if path('main.go')->exists; | |||
| 330 | ||||||
| 331 | # Check for .go files | |||||
| 332 | 22 | 251 | my @go_files = path('.')->children(qr/\.go$/); | |||
| 333 | 22 | 717 | $score += 3 if @go_files > 0; | |||
| 334 | 22 | 22 | $score += 1 if @go_files > 3; | |||
| 335 | ||||||
| 336 | 22 | 18 | return $score; | |||
| 337 | } | |||||
| 338 | ||||||
| 339 | 21 21 | 3496 13 | sub _detect_ruby() { | |||
| 340 | 21 | 10 | my $score = 0; | |||
| 341 | ||||||
| 342 | # Strong indicators | |||||
| 343 | 21 | 21 | $score += 15 if path('Gemfile')->exists; | |||
| 344 | 21 | 265 | $score += 10 if path('Gemfile.lock')->exists; | |||
| 345 | ||||||
| 346 | # Medium indicators | |||||
| 347 | 21 | 242 | $score += 5 if path('Rakefile')->exists; | |||
| 348 | 21 | 249 | $score += 4 if path('.ruby-version')->exists; | |||
| 349 | 21 | 239 | $score += 3 if path('config.ru')->exists; | |||
| 350 | ||||||
| 351 | # Check for .rb files | |||||
| 352 | 21 | 282 | my @rb_files = path('.')->children(qr/\.rb$/); | |||
| 353 | 21 | 595 | $score += 2 if @rb_files > 0; | |||
| 354 | ||||||
| 355 | 21 | 16 | return $score; | |||
| 356 | } | |||||
| 357 | ||||||
| 358 | 20 20 | 3178 17 | sub _detect_docker() { | |||
| 359 | 20 | 8 | my $score = 0; | |||
| 360 | ||||||
| 361 | # Strong indicators | |||||
| 362 | 20 | 22 | $score += 12 if path('Dockerfile')->exists; | |||
| 363 | 20 | 258 | $score += 8 if path('docker-compose.yml')->exists; | |||
| 364 | 20 | 301 | $score += 8 if path('docker-compose.yaml')->exists; | |||
| 365 | ||||||
| 366 | # Medium indicators | |||||
| 367 | 20 | 233 | $score += 3 if path('.dockerignore')->exists; | |||
| 368 | ||||||
| 369 | 20 | 239 | return $score; | |||
| 370 | } | |||||
| 371 | ||||||
| 372 | 21 21 | 3520 11 | sub _detect_php() { | |||
| 373 | 21 | 12 | my $score = 0; | |||
| 374 | ||||||
| 375 | # Strong indicators | |||||
| 376 | 21 | 22 | $score += 15 if path('composer.json')->exists; | |||
| 377 | 21 | 272 | $score += 10 if path('composer.lock')->exists; | |||
| 378 | ||||||
| 379 | # Medium indicators | |||||
| 380 | 21 | 252 | $score += 5 if path('phpunit.xml')->exists; | |||
| 381 | 21 | 236 | $score += 5 if path('phpunit.xml.dist')->exists; | |||
| 382 | 21 | 276 | $score += 3 if path('.php-version')->exists; | |||
| 383 | ||||||
| 384 | # Weak indicators | |||||
| 385 | 21 | 240 | $score += 2 if path('src')->exists && path('src')->is_dir; | |||
| 386 | 21 | 247 | $score += 2 if path('tests')->exists && path('tests')->is_dir; | |||
| 387 | ||||||
| 388 | # Check for .php files | |||||
| 389 | 21 | 246 | my @php_files = path('.')->children(qr/\.php$/); | |||
| 390 | 21 | 610 | $score += 2 if @php_files > 0; | |||
| 391 | ||||||
| 392 | 21 | 15 | return $score; | |||
| 393 | } | |||||
| 394 | ||||||
| 395 | 22 22 | 5230 10 | sub _detect_java() { | |||
| 396 | 22 | 15 | my $score = 0; | |||
| 397 | ||||||
| 398 | # Strong indicators | |||||
| 399 | 22 | 41 | $score += 15 if path('pom.xml')->exists; | |||
| 400 | 22 | 279 | $score += 15 if path('build.gradle')->exists; | |||
| 401 | 22 | 249 | $score += 15 if path('build.gradle.kts')->exists; | |||
| 402 | ||||||
| 403 | # Medium indicators | |||||
| 404 | 22 | 741 | $score += 5 if path('gradlew')->exists; | |||
| 405 | 22 | 313 | $score += 5 if path('mvnw')->exists; | |||
| 406 | 22 | 254 | $score += 4 if path('settings.gradle')->exists; | |||
| 407 | 22 | 279 | $score += 4 if path('settings.gradle.kts')->exists; | |||
| 408 | ||||||
| 409 | # Weak indicators | |||||
| 410 | 22 | 261 | $score += 3 if path('src/main/java')->exists; | |||
| 411 | 22 | 259 | $score += 2 if path('src/test/java')->exists; | |||
| 412 | ||||||
| 413 | # Check for .java files | |||||
| 414 | 22 | 238 | my @java_files = path('.')->children(qr/\.java$/); | |||
| 415 | 22 | 623 | $score += 2 if @java_files > 0; | |||
| 416 | ||||||
| 417 | 22 | 15 | return $score; | |||
| 418 | } | |||||
| 419 | ||||||
| 420 | 21 21 | 3513 12 | sub _detect_cpp() { | |||
| 421 | 21 | 12 | my $score = 0; | |||
| 422 | ||||||
| 423 | # Strong indicators | |||||
| 424 | 21 | 18 | $score += 12 if path('CMakeLists.txt')->exists; | |||
| 425 | 21 | 306 | $score += 8 if path('Makefile')->exists; | |||
| 426 | 21 | 257 | $score += 6 if path('configure.ac')->exists; | |||
| 427 | 21 | 244 | $score += 6 if path('configure')->exists; | |||
| 428 | ||||||
| 429 | # Medium indicators | |||||
| 430 | 21 | 262 | $score += 4 if path('meson.build')->exists; | |||
| 431 | 21 | 257 | $score += 3 if path('.clang-format')->exists; | |||
| 432 | ||||||
| 433 | # Check for C++ files | |||||
| 434 | 21 | 256 | my @cpp_files = path('.')->children(qr/\.(?:cpp|cc|cxx|hpp|hxx|h)$/); | |||
| 435 | 21 | 603 | $score += 3 if @cpp_files > 0; | |||
| 436 | 21 | 22 | $score += 2 if @cpp_files > 5; | |||
| 437 | ||||||
| 438 | # Check for include directory | |||||
| 439 | 21 | 21 | $score += 2 if path('include')->exists && path('include')->is_dir; | |||
| 440 | ||||||
| 441 | 21 | 284 | return $score; | |||
| 442 | } | |||||
| 443 | ||||||
| 444 - 455 | =head1 AUTHOR Nigel Horne E<lt>njh@nigelhorne.comE<gt> L<https://github.com/nigelhorne> =head1 LICENSE This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself. =cut | |||||
| 456 | ||||||
| 457 | 1; | |||||