Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,44 @@ java -jar target/cognitive-analysis.jar analyse src/main/java,tools/Generator.ja
Directories are searched recursively for `.java` files, skipping hidden directories.
A file that cannot be parsed is reported and skipped, and the analysis goes on.

The console output starts with the configuration in use and the cache state, then one table per class:

```text
Config: built-in
Cache: enabled

Found 1 files. Starting analysis.

Class: test.testcode.ClassToAnalyze
File: src/test/resources/fixtures/sample/ClassToAnalyze.java
┌────────────────────────┬────────┬───────────┬─────────┬───────────┬──────────┬───────┬────────────┬───────────┬────────────┐
│ Method Name │ Lines │ Arguments │ Returns │ Variables │ Property │ If │ If Nesting │ Else │ Cognitive │
│ │ │ │ │ │ Accesses │ │ Level │ │ Complexity │
├────────────────────────┼────────┼───────────┼─────────┼───────────┼──────────┼───────┼────────────┼───────────┼────────────┤
│ simpleMethod │ 3 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 0.000 │
│ methodWithTwoArguments │ 16 (0) │ 2 (0) │ 0 (0) │ 0 (0) │ 0 (0) │ 3 (0) │ 3 (1.099) │ 3 (1.099) │ 2.197 │
└────────────────────────┴────────┴───────────┴─────────┴───────────┴──────────┴───────┴────────────┴───────────┴────────────┘
```

A cell of the detail columns reads `count (weight)`, with the weight shown with up to three decimals.
The score is red above `scoreThreshold` and green otherwise, Halstead values and risk levels are yellow or red from their thresholds.
Colors are used only on a terminal, and `NO_COLOR` switches them off.
The settings that choose the columns and the grouping are described in [Configuration](./docs/Configuration.md#console-output).

Order the methods with `--sort-by <field>` and `--sort-order asc|desc` (default `asc`):

```bash
java -jar target/cognitive-analysis.jar analyse src/main/java --sort-by score --sort-order desc
```

The fields are `score`, `halstead`, `cyclomatic`, `understandability`, `class`, `method`, `file`, the raw counts `lineCount`, `argCount`, `returnCount`, `variableCount`, `propertyCallCount`, `ifCount`, `ifNestingLevel`, `elseCount`, and the weight of each count as `<count>Weight`, for example `ifCountWeight`.
Numbers are compared as numbers and text without regard to letter case; methods with an equal value stay in order of class and method.
Reports follow the same order.
Within a class table the methods keep the sort order, and the class tables themselves are always ordered by class name.

Use `--debug` to print the time of each stage (`config`, `discovery`, `analysis`, `sorting`, `output`) and the counters `filesFound`, `filesAnalysed`, `filesFailed`, `methodsAnalysed`, `cacheHits` and `cacheMisses` after the run.
On a terminal, a progress line written to the terminal itself shows how many files are analysed; it never reaches redirected output.

Write an HTML, CSV or JSON report instead:

```bash
Expand Down
24 changes: 22 additions & 2 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ Options:

## Settings

`metrics`, `excludeFilePatterns`, `excludePatterns` and `cache` affect the analysis today.
The other settings (`scoreThreshold`, `show*`, `groupByClass`, `customReporters`) are parsed and validated, and are applied from a later release.
`metrics`, `excludeFilePatterns`, `excludePatterns` and `cache` affect the analysis.
`scoreThreshold`, `show*` and `groupByClass` shape the console output, see [Console output](#console-output).
`customReporters` is parsed and validated, and is applied from a later release.

```yaml
cognitive:
Expand Down Expand Up @@ -107,6 +108,25 @@ cognitive:

Use single quotes in YAML, so a backslash reaches the regular expression unchanged.

## Console output

These settings change only what the console prints, never the score or the reports.

| Setting | Effect |
|---|---|
| `scoreThreshold` | A score above it is red, any other score is green. |
| `showOnlyMethodsExceedingThreshold` | Hides the methods whose score is at or below `scoreThreshold`. A class or table without any remaining method is not printed. |
| `groupByClass` | `true` prints `Class:` and `File:` lines and one table per class, ordered by class name. `false` prints `All Methods (N total)` and one table with a leading `Class` column. |
| `showDetailedCognitiveMetrics` | Adds the columns Lines, Arguments, Returns, Variables, Property Accesses, If, If Nesting Level and Else, each as `count (weight)`. |
| `showHalsteadComplexity` | Adds Halstead Volume, Difficulty and Effort with two decimals. Volume is yellow from 100 and red from 1000, difficulty from 10 and 50, effort from 500 and 5000. |
| `showCyclomaticComplexity` | Adds the Cyclomatic Complexity as `n (risk)`. |
| `showUnderstandability` | Adds the Understandability as `n (risk)`. |

The risk level is yellow for `medium` and red for `high` and `very_high`.
The Cognitive Complexity column is always shown.

The first lines of the output name the configuration file, relative to the working directory when it lies below it, or `built-in`, and say whether the cache is `enabled` or `disabled`.

## Cache

The analysis caches the metrics of each file, so a file that did not change is not parsed again.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import java.io.PrintWriter;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.function.Supplier;
import java.util.function.LongSupplier;
import net.floriankraemer.cognitive_analysis.CognitiveAnalysisException;
import net.floriankraemer.cognitive_analysis.application.report.CsvReportBuilder;
import net.floriankraemer.cognitive_analysis.application.report.HtmlReportBuilder;
Expand All @@ -15,9 +17,11 @@
import net.floriankraemer.cognitive_analysis.application.report.ReportBuilder.ReportType;
import net.floriankraemer.cognitive_analysis.config.CognitiveConfig;
import net.floriankraemer.cognitive_analysis.config.ConfigLoader;
import net.floriankraemer.cognitive_analysis.domain.CognitiveMetrics;
import net.floriankraemer.cognitive_analysis.domain.CognitiveMetricsFacade;
import net.floriankraemer.cognitive_analysis.domain.MetricsCollection;
import net.floriankraemer.cognitive_analysis.domain.MetricsSorter;
import net.floriankraemer.cognitive_analysis.domain.ScoredMethod;
import picocli.CommandLine.Help.Ansi;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
Expand Down Expand Up @@ -46,6 +50,14 @@ public ReportType convert(final String value) {
}
}

/** The values of {@code --sort-by}, for the help text. */
static final class SortFields implements Iterable<String> {
@Override
public Iterator<String> iterator() {
return MetricsSorter.FIELDS.iterator();
}
}

@Spec
CommandSpec spec;

Expand All @@ -69,31 +81,80 @@ public ReportType convert(final String value) {
@Option(names = {"-f", "--report-file"}, description = "File the report is written to. Requires --report-type.")
String reportFile;

private final ResultTableBuilder resultTableBuilder = new ResultTableBuilder();
@Option(
names = "--sort-by",
paramLabel = "<field>",
completionCandidates = SortFields.class,
description = "Sort the methods by a field. Available fields: ${COMPLETION-CANDIDATES}."
)
String sortBy;

@Option(
names = "--sort-order",
paramLabel = "<order>",
defaultValue = "asc",
description = "Sort order: asc or desc. Default: ${DEFAULT-VALUE}."
)
String sortOrder;

@Option(names = "--debug", description = "Print the time of each stage and statistics after the run.")
boolean debug;

/** Replaceable by tests: the clock of the stage timings, the colors and the terminal check. */
LongSupplier ticker = System::nanoTime;
Ansi ansi = Ansi.AUTO;
/** The terminal the progress goes to, or null when the process is not interactive. */
Supplier<PrintWriter> terminalWriter = () -> Terminal.isAttached() ? System.console().writer() : null;

private final ReportBuilder reportBuilder = new ReportBuilder(
new HtmlReportBuilder(), new CsvReportBuilder(), new JsonReportBuilder());

@Override
public Integer call() {
final PrintWriter err = spec.commandLine().getErr();
final ProgressIndicator progress = new ProgressIndicator(terminalWriter.get(), ansi.enabled());
final PrintWriter err = progress.clearingBefore(spec.commandLine().getErr());

if ((reportType == null) != (reportFile == null)) {
err.println("Both report type and file must be provided.");
return 1;
}

try {
final ExecutionSummary summary = new ExecutionSummary(ticker);
final MetricsSorter sorter = new MetricsSorter(sortBy, sortOrder);
final PrintWriter out = spec.commandLine().getOut();

final CognitiveConfig config =
new ConfigLoader(Path.of("")).load(configFile == null ? null : Path.of(configFile));
final MetricsCollection metrics =
new CognitiveMetricsFacade(config, err, Path.of("")).calculateCognitiveMetrics(paths());
final PrintWriter out = spec.commandLine().getOut();
final ConsoleRenderer console = new ConsoleRenderer(config, ansi);
summary.endStage("config");

final CognitiveMetricsFacade facade = new CognitiveMetricsFacade(config, err, Path.of(""));
final List<Path> files = facade.findFiles(paths());
console.renderHeader(out, Path.of(""));
out.println("Found " + files.size() + " files. Starting analysis.");
summary.endStage("discovery");

progress.start(files.size());
final MetricsCollection analysed = facade.analyse(files, file -> progress.advance());
progress.clear();
summary.endStage("analysis");

final List<ScoredMethod> methods = sorter.sort(analysed.methods());
summary.endStage("sorting");

if (reportType != null) {
reportBuilder.build(reportType, reportFile, metrics);
reportBuilder.build(reportType, reportFile, MetricsCollection.of(methods));
out.println("Report generated at: " + reportFile);
} else {
renderConsoleOutput(out, metrics);
console.renderMethods(out, methods);
out.println();
out.println("Cognitive metrics calculated for " + path);
}
summary.endStage("output");

if (debug) {
summary.print(out, facade.statistics().asMap());
}
return 0;
} catch (CognitiveAnalysisException | RuntimeException e) {
Expand All @@ -115,15 +176,4 @@ private List<String> paths() throws CognitiveAnalysisException {
static String describe(final Exception e) {
return e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
}

private void renderConsoleOutput(final PrintWriter out, final MetricsCollection metrics) {
for (Map.Entry<String, Map<String, CognitiveMetrics>> entry : metrics.entrySet()) {
out.println();
out.println("File: " + entry.getKey());
out.println(resultTableBuilder.build(entry.getValue()));
}

out.println();
out.println("Cognitive metrics calculated for " + path);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package net.floriankraemer.cognitive_analysis.application;

import java.io.PrintWriter;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import net.floriankraemer.cognitive_analysis.config.CognitiveConfig;
import net.floriankraemer.cognitive_analysis.domain.CognitiveMetrics;
import net.floriankraemer.cognitive_analysis.domain.ScoredMethod;
import picocli.CommandLine.Help.Ansi;

/** Prints the run header and the method tables to the console. */
final class ConsoleRenderer {

private final CognitiveConfig config;
private final ResultTableBuilder tables;

ConsoleRenderer(final CognitiveConfig config, final Ansi ansi) {
this.config = config;
this.tables = new ResultTableBuilder(config, ansi);
}

/** The configuration in use and the cache state, followed by a blank line. */
void renderHeader(final PrintWriter out, final Path workingDirectory) {
out.println("Config: " + config.source().map(source -> displayPath(source, workingDirectory)).orElse("built-in"));
out.println("Cache: " + (config.cache().enabled() ? "enabled" : "disabled"));
out.println();
}

/** The methods in the given order, grouped by class or in one table, as the configuration says. */
void renderMethods(final PrintWriter out, final List<ScoredMethod> methods) {
final List<ScoredMethod> shown = methods.stream().filter(this::isShown).toList();
if (shown.isEmpty()) {
return;
}
if (config.groupByClass()) {
renderByClass(out, shown);
return;
}
out.println();
out.println("All Methods (" + shown.size() + " total)");
out.println(tables.buildWithClass(metricsOf(shown)));
}

private boolean isShown(final ScoredMethod method) {
return !config.showOnlyMethodsExceedingThreshold()
|| method.metrics().getCognitiveComplexity() > config.scoreThreshold();
}

/** A class is listed once per file, so the same class name in two source roots gets a table for each. */
private record ClassInFile(String className, String file) {}

private void renderByClass(final PrintWriter out, final List<ScoredMethod> methods) {
final Map<ClassInFile, List<ScoredMethod>> byClass = new TreeMap<>(
Comparator.comparing(ClassInFile::className).thenComparing(ClassInFile::file));
for (final ScoredMethod method : methods) {
final ClassInFile key = new ClassInFile(method.metrics().getClassName(), method.file());
byClass.computeIfAbsent(key, k -> new ArrayList<>()).add(method);
}
byClass.forEach((group, classMethods) -> {
out.println();
out.println("Class: " + group.className());
out.println("File: " + group.file());
out.println(tables.build(metricsOf(classMethods)));
});
}

private static List<CognitiveMetrics> metricsOf(final List<ScoredMethod> methods) {
return methods.stream().map(ScoredMethod::metrics).toList();
}

/** Relative to the working directory when the file lies below it, else absolute. */
private static String displayPath(final Path file, final Path workingDirectory) {
final Path absolute = file.toAbsolutePath().normalize();
final Path base = workingDirectory.toAbsolutePath().normalize();
return absolute.startsWith(base) ? base.relativize(absolute).toString() : absolute.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package net.floriankraemer.cognitive_analysis.application;

import java.io.PrintWriter;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.function.LongSupplier;

/** Measures the stages of a run and prints the {@code --debug} summary. */
final class ExecutionSummary {

private static final double NANOS_PER_SECOND = 1_000_000_000.0;

private final LongSupplier ticker;
private final long start;
private long lastMark;
private final Map<String, Long> stages = new LinkedHashMap<>();

/** @param ticker the current time in nanoseconds; only differences are used */
ExecutionSummary(final LongSupplier ticker) {
this.ticker = ticker;
this.start = ticker.getAsLong();
this.lastMark = start;
}

/** Ends the stage that began where the previous one ended. */
void endStage(final String name) {
final long now = ticker.getAsLong();
stages.put(name, now - lastMark);
lastMark = now;
}

void print(final PrintWriter out, final Map<String, Integer> statistics) {
out.println("Execution Summary:");
out.println(" Total execution time: " + seconds(lastMark - start));
out.println("Stage timings:");
stages.forEach((name, nanos) -> out.println(" " + name + ": " + seconds(nanos)));
out.println("Statistics:");
statistics.forEach((name, value) -> out.println(" " + name + ": " + value));
}

private static String seconds(final long nanos) {
return String.format(Locale.ROOT, "%.3fs", nanos / NANOS_PER_SECOND);
}
}
Loading
Loading