diff --git a/CHANGELOG.md b/CHANGELOG.md index 02bc0efa..b52b4a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Changed +- Performance: the HTML report emits each page's code table in one awk pass and stops forking `cat` for every markup block — 9.5s to 4.5s for 128 files, and 58.7s to 4.5s together with the escaping fix (#1098) - Performance: `--coverage-report-html` no longer forks twice per source line to escape it — 58.7s to 9.5s for 128 files here, with the escaping done once per file and the per-page `wc`, `basename` and `pwd` calls replaced by parameter expansions (#1096) - Performance: the coverage report picks its colours without a subshell per file and per function — the text report over 128 files went from 387ms to 318ms, and 4042ms to 3116ms with `BASHUNIT_COVERAGE_SHOW_FUNCTIONS` on (#1092) diff --git a/src/coverage/html_file.sh b/src/coverage/html_file.sh index 7d56ac97..4fc4208c 100644 --- a/src/coverage/html_file.sh +++ b/src/coverage/html_file.sh @@ -2,6 +2,116 @@ # HTML coverage report: the per-file page. +# The code table of one page, in one awk pass. +# +# The Bash loop that did this classified, looked up and echoed per source line: +# 8116ms for 128 pages over 22,405 lines, with no forks left in it -- Bash is +# simply the wrong tool for emitting 7MB of markup (#1098). Every input it +# needs is already a file: the aggregated hits, the per-line test list and the +# source itself. +# +# Composed with the classifier rules, which are included ahead of it. +# shellcheck disable=SC2016 +_BASHUNIT_COVERAGE_AWK_HTML_ROWS=' +FILENAME == hitsfile { + hits[$1 + 0] = $2 + 0 + next +} + +FILENAME == testsfile { + # "|:", deduplicated, first-seen order kept. + p = index($0, "|") + if (p == 0) { next } + tln = substr($0, 1, p - 1) + 0 + info = substr($0, p + 1) + key = tln SUBSEP info + if (key in seen) { next } + seen[key] = 1 + tests[tln] = (tln in tests) ? tests[tln] "\n" info : info + next +} + +{ + total++ + sl[total] = $0 +} + +function escape(t) { + gsub(/&/, "\\&", t) + gsub(//, "\\>", t) + return t +} + +END { + # The DEBUG trap attributes a multi-line statement to its starting line, so + # the count carries forward across the backslash chain (#722). + carry = 0 + for (ln = 1; ln <= total; ln++) { + h = (ln in hits) ? hits[ln] : 0 + if (carry > 0 && h < carry) { h = carry; hits[ln] = h } + if (h > 0 && bu_ends_with_continuation(sl[ln])) { carry = h } else { carry = 0 } + } + + for (ln = 1; ln <= total; ln++) { + row_class = "" + hits_display = "" + + if (bu_is_executable(sl[ln])) { + h = (ln in hits) ? hits[ln] : 0 + if (h > 0) { + row_class = "covered" + if (ln in tests) { + tooltip = "
Tests hitting this line
    " + n = split(tests[ln], entries, "\n") + for (e = 1; e <= n; e++) { + if (entries[e] == "") { continue } + c = index(entries[e], ":") + if (c == 0) { tfile = entries[e]; tfn = "" } else { tfile = substr(entries[e], 1, c - 1); tfn = substr(entries[e], c + 1) } + sub(/^.*\//, "", tfile) + tooltip = tooltip "
  • " tfile ":" tfn "
  • " + } + tooltip = tooltip "
" + hits_display = "" h times tooltip "" + } else { + hits_display = "" h times "" + } + } else { + row_class = "uncovered" + hits_display = "" h times "" + } + } + + printf " \n", ln, row_class + printf " %s\n", ln + printf " %s\n", hits_display + printf " %s\n", escape(sl[ln]) + printf " \n" + } +} +' + +## +# Emits the code-table rows of one page. +# Arguments: $1 - source file, $2 - file holding its per-line test list +## +function bashunit::coverage::html_code_rows() { + local file="$1" tests_file="$2" + + bashunit::coverage::ensure_hits_aggregated + bashunit::coverage::hits_file_for "$file" + local hits_file="$_BASHUNIT_COVERAGE_HITS_FILE_OUT" + if [ -z "$hits_file" ] || [ ! -f "$hits_file" ]; then + hits_file="/dev/null" + fi + + # The multiplication sign comes in as a value, not as an awk escape: `\x` is + # not POSIX awk, so the byte sequence stays on the shell side. + env LC_ALL=C "$AWK" -v hitsfile="$hits_file" -v testsfile="$tests_file" -v times="×" \ + "${_BASHUNIT_COVERAGE_AWK_RULES}${_BASHUNIT_COVERAGE_AWK_HTML_ROWS}" \ + "$hits_file" "$tests_file" "$file" +} + function bashunit::coverage::generate_file_html() { local file="$1" local output_file="$2" @@ -27,47 +137,20 @@ function bashunit::coverage::generate_file_html() { ((++_fli)) done <"$file" - # And their escaped form, in ONE awk pass for the whole file. Escaping per - # line cost a command substitution and a sed each -- about 22,000 processes - # for this repo, 58.7s of HTML report (#1096). - local -a escaped_lines=() - local _eli=0 _el - while IFS= read -r _el || [ -n "$_el" ]; do - escaped_lines[_eli]="$_el" - ((++_eli)) - done < <(bashunit::coverage::html_escape_file "$file") - - # Pre-load test hits data into indexed array (for tooltips) - # Index: line number, Value: newline-separated list of "test_file:test_function" - # Using indexed array for Bash 3.0 compatibility (no associative arrays) - local -a tests_by_line=() - local _line_and_test - while IFS= read -r _line_and_test; do - [ -z "$_line_and_test" ] && continue - local _tln="${_line_and_test%%|*}" - local _tinfo="${_line_and_test#*|}" - if [ -n "${tests_by_line[_tln]:-}" ]; then - # Append only if not already present (avoid duplicates) - # Use newline boundaries to prevent false positives (e.g., test_foo matching test_foo_bar) - case $'\n'"${tests_by_line[_tln]}"$'\n' in - *$'\n'"$_tinfo"$'\n'*) - # already present, skip - ;; - *) - tests_by_line[_tln]="${tests_by_line[_tln]}"$'\n'"${_tinfo}" - ;; - esac - else - tests_by_line[_tln]="$_tinfo" - fi - done < <(bashunit::coverage::get_all_line_tests "$file") + # The per-line test list, for the tooltips. It goes to a file because the row + # emitter below is one awk pass that reads it alongside the hits and the + # source (#1098). + local tests_file="${_BASHUNIT_COVERAGE_DATA_FILE%/*}/page-tests" + if ! bashunit::coverage::get_all_line_tests "$file" >"$tests_file" 2>/dev/null; then + : >"$tests_file" + fi # Count total lines and functions local total_lines="${#file_lines[@]}" local non_executable=$((total_lines - executable)) { - cat <<'EOF' + bashunit::coverage::emit_block <<'EOF' @@ -75,7 +158,7 @@ function bashunit::coverage::generate_file_html() { EOF echo " ${display_file##*/} | Coverage Report" - cat <<'EOF' + bashunit::coverage::emit_block <<'EOF'