| 1 | // Copyright (c) 2024 Felipe Pena and Delyan Angelov. All rights reserved. |
| 2 | // Use of this source code is governed by an MIT license |
| 3 | // that can be found in the LICENSE file. |
| 4 | module main |
| 5 | |
| 6 | import os |
| 7 | import log |
| 8 | import flag |
| 9 | import json |
| 10 | import arrays |
| 11 | import encoding.csv |
| 12 | |
| 13 | // program options, storage etc |
| 14 | struct Context { |
| 15 | mut: |
| 16 | show_help bool |
| 17 | show_hotspots bool |
| 18 | show_percentages bool |
| 19 | show_test_files bool |
| 20 | use_absolute_paths bool |
| 21 | be_verbose bool |
| 22 | lcov_output string |
| 23 | filter string |
| 24 | working_folder string |
| 25 | |
| 26 | targets []string |
| 27 | meta map[string]MetaData // aggregated meta data, read from all .json files |
| 28 | all_lines_per_file map[string][]int // aggregated by load_meta |
| 29 | |
| 30 | counters map[string]u64 // incremented by process_target, based on each .csv file |
| 31 | lines_per_file map[string]map[int]int // incremented by process_target, based on each .csv file |
| 32 | processed_points u64 |
| 33 | } |
| 34 | |
| 35 | const metadata_extension = '.json' |
| 36 | const vcounter_glob_pattern = 'vcounters_*.csv' |
| 37 | |
| 38 | fn (mut ctx Context) load_meta(folder string) { |
| 39 | for omfile in os.walk_ext(folder, metadata_extension) { |
| 40 | mfile := omfile.replace('\\', '/') |
| 41 | content := os.read_file(mfile) or { '' } |
| 42 | meta := os.file_name(mfile.replace(metadata_extension, '')) |
| 43 | data := json.decode(MetaData, content) or { |
| 44 | log.error('${@METHOD} failed to load ${mfile}') |
| 45 | continue |
| 46 | } |
| 47 | ctx.meta[meta] = data |
| 48 | mut lines_per_file := ctx.all_lines_per_file[data.file] |
| 49 | lines_per_file << data.points |
| 50 | ctx.all_lines_per_file[data.file] = arrays.distinct(lines_per_file) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | fn (mut ctx Context) post_process_all_metas() { |
| 55 | ctx.verbose('${@METHOD}') |
| 56 | for _, m in ctx.meta { |
| 57 | lines_per_file := ctx.all_lines_per_file[m.file] |
| 58 | for line in lines_per_file { |
| 59 | ctx.counters['${m.file}:${line}:'] = 0 |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | fn (mut ctx Context) post_process_all_targets() { |
| 65 | ctx.verbose('${@METHOD}') |
| 66 | ctx.verbose('ctx.processed_points: ${ctx.processed_points}') |
| 67 | } |
| 68 | |
| 69 | fn (ctx &Context) verbose(msg string) { |
| 70 | if ctx.be_verbose { |
| 71 | log.info(msg) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | fn (mut ctx Context) process_target(tfile string) ! { |
| 76 | ctx.verbose('${@METHOD} ${tfile}') |
| 77 | mut reader := csv.new_reader_from_file(tfile)! |
| 78 | header := reader.read()! |
| 79 | if header != ['meta', 'point', 'hits'] { |
| 80 | return error('invalid header in .csv file') |
| 81 | } |
| 82 | for { |
| 83 | row := reader.read() or { break } |
| 84 | mut cline := CounterLine{ |
| 85 | meta: row[0] |
| 86 | point: row[1].int() |
| 87 | hits: row[2].u64() |
| 88 | } |
| 89 | m := ctx.meta[cline.meta] or { |
| 90 | ctx.verbose('> skipping invalid meta: ${cline.meta} in file: ${cline.file}, csvfile: ${tfile}') |
| 91 | continue |
| 92 | } |
| 93 | cline.file = m.file |
| 94 | cline.line = m.points[cline.point] or { |
| 95 | ctx.verbose('> skipping invalid point: ${cline.point} in file: ${cline.file}, meta: ${cline.meta}, csvfile: ${tfile}') |
| 96 | continue |
| 97 | } |
| 98 | ctx.counters['${cline.file}:${cline.line}:'] += cline.hits |
| 99 | mut lines := ctx.lines_per_file[cline.file].move() |
| 100 | lines[cline.line]++ |
| 101 | ctx.lines_per_file[cline.file] = lines.move() |
| 102 | // dump( ctx.lines_per_file[cline.meta][cline.point] ) |
| 103 | ctx.processed_points++ |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | fn (mut ctx Context) show_report() ! { |
| 108 | filters := ctx.filter.split(',').filter(it != '') |
| 109 | if ctx.show_hotspots { |
| 110 | mut locations := []string{cap: ctx.counters.len} |
| 111 | for location, _ in ctx.counters { |
| 112 | if !ctx.matches_filters(location, filters) { |
| 113 | continue |
| 114 | } |
| 115 | locations << location |
| 116 | } |
| 117 | locations.sort() |
| 118 | for location in locations { |
| 119 | hits := ctx.counters[location] |
| 120 | mut final_path := normalize_path(location) |
| 121 | if !ctx.use_absolute_paths { |
| 122 | final_path = location.all_after_first('${ctx.working_folder}/') |
| 123 | } |
| 124 | println('${hits:-8} ${final_path}') |
| 125 | } |
| 126 | } |
| 127 | if ctx.show_percentages { |
| 128 | for file in ctx.sorted_hit_files(filters) { |
| 129 | total_lines := ctx.all_lines_per_file[file].len |
| 130 | executed_points := ctx.lines_per_file[file].len |
| 131 | coverage_percent := 100.0 * f64(executed_points) / f64(total_lines) |
| 132 | mut final_path := normalize_path(file) |
| 133 | if !ctx.use_absolute_paths { |
| 134 | final_path = file.all_after_first('${ctx.working_folder}/') |
| 135 | } |
| 136 | println('${final_path:-80s} | ${executed_points:6} | ${total_lines:6} | ${coverage_percent:6.2f}%') |
| 137 | } |
| 138 | } |
| 139 | if ctx.lcov_output != '' { |
| 140 | ctx.write_lcov_report(filters)! |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | fn normalize_path(path string) string { |
| 145 | return path.replace(os.path_separator, '/') |
| 146 | } |
| 147 | |
| 148 | fn (ctx &Context) matches_filters(path string, filters []string) bool { |
| 149 | if filters.len == 0 { |
| 150 | return true |
| 151 | } |
| 152 | return filters.any(path.contains(it)) |
| 153 | } |
| 154 | |
| 155 | fn (ctx &Context) should_include_file(file string, filters []string) bool { |
| 156 | if !ctx.show_test_files && (file.ends_with('_test.v') || file.ends_with('_test.c.v')) { |
| 157 | return false |
| 158 | } |
| 159 | return ctx.matches_filters(file, filters) |
| 160 | } |
| 161 | |
| 162 | fn (ctx &Context) sorted_hit_files(filters []string) []string { |
| 163 | mut files := []string{} |
| 164 | for file, _ in ctx.lines_per_file { |
| 165 | if !ctx.should_include_file(file, filters) { |
| 166 | continue |
| 167 | } |
| 168 | files << file |
| 169 | } |
| 170 | files.sort() |
| 171 | return files |
| 172 | } |
| 173 | |
| 174 | fn (ctx &Context) sorted_files(filters []string) []string { |
| 175 | mut files := []string{} |
| 176 | for file, _ in ctx.all_lines_per_file { |
| 177 | if !ctx.should_include_file(file, filters) { |
| 178 | continue |
| 179 | } |
| 180 | files << file |
| 181 | } |
| 182 | files.sort() |
| 183 | return files |
| 184 | } |
| 185 | |
| 186 | fn (ctx &Context) write_lcov_report(filters []string) ! { |
| 187 | output_path := os.real_path(ctx.lcov_output) |
| 188 | output_dir := os.dir(output_path) |
| 189 | if output_dir != '' && !os.exists(output_dir) { |
| 190 | os.mkdir_all(output_dir)! |
| 191 | } |
| 192 | mut output := []string{} |
| 193 | for file in ctx.sorted_files(filters) { |
| 194 | mut lines := ctx.all_lines_per_file[file].clone() |
| 195 | lines.sort() |
| 196 | hit_lines := ctx.lines_per_file[file].len |
| 197 | output << 'TN:' |
| 198 | output << 'SF:${normalize_path(file)}' |
| 199 | for line in lines { |
| 200 | hits := ctx.counters['${file}:${line}:'] |
| 201 | output << 'DA:${line},${hits}' |
| 202 | } |
| 203 | output << 'LF:${lines.len}' |
| 204 | output << 'LH:${hit_lines}' |
| 205 | output << 'end_of_record' |
| 206 | } |
| 207 | os.write_file(output_path, output.join_lines())! |
| 208 | ctx.verbose('Wrote LCOV report to ${output_path}') |
| 209 | } |
| 210 | |
| 211 | fn main() { |
| 212 | log.use_stdout() |
| 213 | mut ctx := Context{} |
| 214 | ctx.working_folder = normalize_path(os.real_path(os.getwd())) |
| 215 | mut fp := flag.new_flag_parser(os.args#[1..]) |
| 216 | fp.application('v cover') |
| 217 | fp.version('0.0.2') |
| 218 | fp.description('Analyze & make reports, based on cover files, produced by running programs and tests, compiled with `-coverage folder/`') |
| 219 | fp.arguments_description('[folder1/ file2 ...]') |
| 220 | fp.skip_executable() |
| 221 | ctx.show_help = fp.bool('help', `h`, false, 'Show this help text.') |
| 222 | ctx.be_verbose = fp.bool('verbose', `v`, false, |
| 223 | 'Be more verbose while processing the coverages.') |
| 224 | ctx.show_hotspots = fp.bool('hotspots', `H`, false, |
| 225 | 'Show most frequently executed covered lines.') |
| 226 | ctx.show_percentages = fp.bool('percentages', `P`, true, 'Show coverage percentage per file.') |
| 227 | ctx.lcov_output = fp.string('lcov', 0, '', |
| 228 | 'Write an LCOV line coverage report to the specified file path.') |
| 229 | ctx.show_test_files = fp.bool('show_test_files', `S`, false, |
| 230 | 'Show `_test.v` files as well (normally filtered).') |
| 231 | ctx.use_absolute_paths = fp.bool('absolute', `A`, false, |
| 232 | 'Use absolute paths for all files, no matter the current folder. By default, files inside the current folder, are shown with a relative path.') |
| 233 | ctx.filter = fp.string('filter', `f`, '', 'Filter only the matching source path patterns.') |
| 234 | if ctx.show_help { |
| 235 | println(fp.usage()) |
| 236 | exit(0) |
| 237 | } |
| 238 | targets := fp.finalize() or { |
| 239 | log.error(fp.usage()) |
| 240 | exit(1) |
| 241 | } |
| 242 | ctx.verbose('Targets: ${targets}') |
| 243 | for t in targets { |
| 244 | if !os.exists(t) { |
| 245 | log.error('Skipping ${t}, since it does not exist') |
| 246 | continue |
| 247 | } |
| 248 | if os.is_dir(t) { |
| 249 | found_counter_files := os.walk_ext(t, '.csv') |
| 250 | if found_counter_files.len == 0 { |
| 251 | log.error('Skipping ${t}, since there are 0 ${vcounter_glob_pattern} files in it') |
| 252 | continue |
| 253 | } |
| 254 | for counterfile in found_counter_files { |
| 255 | ctx.targets << counterfile |
| 256 | ctx.load_meta(t) |
| 257 | } |
| 258 | } else { |
| 259 | ctx.targets << t |
| 260 | ctx.load_meta(os.dir(t)) |
| 261 | } |
| 262 | } |
| 263 | ctx.post_process_all_metas() |
| 264 | ctx.verbose('Final ctx.targets.len: ${ctx.targets.len}') |
| 265 | ctx.verbose('Final ctx.meta.len: ${ctx.meta.len}') |
| 266 | ctx.verbose('Final ctx.filter: ${ctx.filter}') |
| 267 | if ctx.targets.len == 0 { |
| 268 | log.error('0 cover targets') |
| 269 | exit(1) |
| 270 | } |
| 271 | for t in ctx.targets { |
| 272 | ctx.process_target(t)! |
| 273 | } |
| 274 | ctx.post_process_all_targets() |
| 275 | ctx.show_report()! |
| 276 | } |
| 277 | |