File Coverage

File:blib/lib/App/GHGen/Detector.pm
Coverage:82.6%

linestmtbrancondsubtimecode
1package 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';
10our @EXPORT_OK = qw(
11        detect_project_type
12        get_project_indicators
13);
14
15our $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
4571;