diff --git a/cmd/tools/vbuild-tools.v b/cmd/tools/vbuild-tools.v index 1c04d7a277203f..cfd368595741ac 100644 --- a/cmd/tools/vbuild-tools.v +++ b/cmd/tools/vbuild-tools.v @@ -11,7 +11,7 @@ import v.util // should be compiled (v folder). // To implement that, these folders are initially skipped, then added // as a whole *after the testing.prepare_test_session call*. -const tools_in_subfolders = ['vast', 'vcreate', 'vdoc', 'vpm', 'vsymlink', 'vvet', 'vwhere'] +const tools_in_subfolders = ['vast', 'vcreate', 'vdoc', 'vpm', 'vsymlink', 'vvet', 'vwhere', 'vcover'] // non_packaged_tools are tools that should not be packaged with // prebuild versions of V, to keep the size smaller. diff --git a/cmd/tools/vcover/cover_test.v b/cmd/tools/vcover/cover_test.v new file mode 100644 index 00000000000000..1a42dea112e334 --- /dev/null +++ b/cmd/tools/vcover/cover_test.v @@ -0,0 +1,104 @@ +import os + +const vexe = @VEXE +const vroot = os.dir(vexe) +const tfolder = os.join_path(os.vtmp_dir(), 'cover_test') + +fn testsuite_begin() { + os.setenv('VCOLORS', 'never', true) + os.chdir(vroot)! + os.rmdir_all(tfolder) or {} + os.mkdir(tfolder) or {} +} + +fn testsuite_end() { + os.rmdir_all(tfolder) or {} +} + +fn test_help() { + res := os.execute('${os.quoted_path(vexe)} cover -h') + assert res.exit_code == 0 + assert res.output.contains('Usage: v cover') + assert res.output.contains('Description: Analyze & make reports') + assert res.output.contains('Options:') + assert res.output.contains('-h, --help Show this help text.') + assert res.output.contains('-v, --verbose Be more verbose while processing the coverages.') + assert res.output.contains('-H, --hotspots Show most frequently executed covered lines.') + assert res.output.contains('-P, --percentages Show coverage percentage per file.') + assert res.output.contains('-S, --show_test_files Show `_test.v` files as well (normally filtered).') + assert res.output.contains('-A, --absolute Use absolute paths for all files') +} + +fn np(path string) string { + return path.replace('\\', '/') +} + +fn test_simple() { + t1 := np(os.join_path(tfolder, 't1')) + t2 := np(os.join_path(tfolder, 't2')) + t3 := np(os.join_path(tfolder, 't3')) + assert !os.exists(t1), t1 + assert !os.exists(t2), t2 + assert !os.exists(t3), t3 + + r1 := os.execute('${os.quoted_path(vexe)} -coverage ${os.quoted_path(t1)} cmd/tools/vcover/testdata/simple/t1_test.v') + assert r1.exit_code == 0, r1.str() + assert r1.output.trim_space() == '10', r1.str() + assert os.exists(t1), t1 + cmd := '${os.quoted_path(vexe)} cover ${os.quoted_path(t1)} --filter vcover/testdata/simple/' + filter1 := os.execute(cmd) + assert filter1.exit_code == 0, filter1.output + assert filter1.output.contains('cmd/tools/vcover/testdata/simple/simple.v'), filter1.output + assert filter1.output.trim_space().ends_with('| 4 | 9 | 44.44%'), filter1.output + hfilter1 := os.execute('${os.quoted_path(vexe)} cover ${os.quoted_path(t1)} --filter vcover/testdata/simple/ -H -P false') + assert hfilter1.exit_code == 0, hfilter1.output + assert !hfilter1.output.contains('%'), hfilter1.output + houtput1 := hfilter1.output.trim_space().split_into_lines() + zeros1 := houtput1.filter(it.starts_with('0 ')) + nzeros1 := houtput1.filter(!it.starts_with('0 ')) + assert zeros1.len > 0 + assert zeros1.any(it.contains('simple.v:12')), zeros1.str() + assert zeros1.any(it.contains('simple.v:14')), zeros1.str() + assert zeros1.any(it.contains('simple.v:17')), zeros1.str() + assert zeros1.any(it.contains('simple.v:18')), zeros1.str() + assert zeros1.any(it.contains('simple.v:19')), zeros1.str() + assert nzeros1.len > 0 + assert nzeros1.any(it.contains('simple.v:4')), nzeros1.str() + assert nzeros1.any(it.contains('simple.v:6')), nzeros1.str() + assert nzeros1.any(it.contains('simple.v:8')), nzeros1.str() + assert nzeros1.any(it.contains('simple.v:25')), nzeros1.str() + + r2 := os.execute('${os.quoted_path(vexe)} -coverage ${os.quoted_path(t2)} cmd/tools/vcover/testdata/simple/t2_test.v') + assert r2.exit_code == 0, r2.str() + assert r2.output.trim_space() == '24', r2.str() + assert os.exists(t2), t2 + filter2 := os.execute('${os.quoted_path(vexe)} cover ${os.quoted_path(t2)} --filter vcover/testdata/simple') + assert filter2.exit_code == 0, filter2.output + assert filter2.output.contains('cmd/tools/vcover/testdata/simple/simple.v') + assert filter2.output.trim_space().ends_with('| 6 | 9 | 66.67%'), filter2.output + hfilter2 := os.execute('${os.quoted_path(vexe)} cover ${os.quoted_path(t2)} --filter testdata/simple -H -P false') + assert hfilter2.exit_code == 0, hfilter2.output + assert !hfilter2.output.contains('%'), hfilter2.output + houtput2 := hfilter2.output.trim_space().split_into_lines() + zeros2 := houtput2.filter(it.starts_with('0 ')) + nzeros2 := houtput2.filter(!it.starts_with('0 ')) + assert zeros2.len > 0 + assert zeros2.any(it.contains('simple.v:4')), zeros2.str() + assert zeros2.any(it.contains('simple.v:6')), zeros2.str() + assert zeros2.any(it.contains('simple.v:8')), zeros2.str() + assert nzeros2.len > 0 + assert nzeros2.any(it.contains('simple.v:17')), nzeros2.str() + assert nzeros2.any(it.contains('simple.v:18')), nzeros2.str() + assert nzeros2.any(it.contains('simple.v:19')), nzeros2.str() + assert nzeros2.any(it.contains('simple.v:25')), nzeros2.str() + + // Run both tests. The coverage should be combined and == 100% + r3 := os.execute('${os.quoted_path(vexe)} -coverage ${os.quoted_path(t3)} test cmd/tools/vcover/testdata/simple/') + assert r3.exit_code == 0, r3.str() + assert r3.output.trim_space().contains('Summary for all V _test.v files: 2 passed'), r3.str() + assert os.exists(t3), t3 + filter3 := os.execute('${os.quoted_path(vexe)} cover ${os.quoted_path(t3)} --filter simple/') + assert filter3.exit_code == 0, filter3.str() + assert filter3.output.contains('cmd/tools/vcover/testdata/simple/simple.v'), filter3.str() + assert filter3.output.trim_space().match_glob('*cmd/tools/vcover/testdata/simple/simple.v *| 9 | 9 | 100.00%'), filter3.str() +} diff --git a/cmd/tools/vcover/data.v b/cmd/tools/vcover/data.v new file mode 100644 index 00000000000000..468cb754e81b78 --- /dev/null +++ b/cmd/tools/vcover/data.v @@ -0,0 +1,34 @@ +// Copyright (c) 2024 Felipe Pena and Delyan Angelov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module main + +// vcounter_*.csv files contain counter lines in a CSV format. They can get quite large, +// for big programs, since they contain all non zero coverage counters. +// Their names are timestamp (rand.ulid + clock_gettime) based, to minimise the chance that parallel runs +// will overwrite each other, but without the overhead of additional synchronisation/locks. +struct CounterLine { +mut: + file string // retrieved based on the loaded meta + line int // retrieved based on the loaded meta + // + meta string // A filename in the sibling meta/ folder, should exist, to match the value from this field. The filename is a hash of both the path and the used build options, to facilitate merging coverage data from different builds/programs + point int // The index of a source point. Note that it is not a line number, but an index in the meta data file, keyed by the field `meta` above. + hits u64 // How many times the coverage point was executed. Only counters that are != 0 are recorded. +} + +// Source metadata files in meta/*.txt, contain JSON encoded fields (mappings from v source files to point line numbers). +// Their names are a result of a hashing function, applied over both the source file name, and the build options. +// This has several benefits: +// a) it makes sure, that the resulting path is normalised +// b) the meta data is deduplicated between runs that use the same source files +// c) coverage data from different runs can be merged by simply reusing the same -coverage folder, +// or by copy/pasting all files from 1 run, to the folder of another. +struct MetaData { + file string // V source file path + fhash string // fhash is the name of the meta file + v_version string // the V version, used to generate the coverage meta data file + build_options string // the build options for the program + npoints int // the number of stored coverage points + points []int // the line numbers corresponding to each point +} diff --git a/cmd/tools/vcover/main.v b/cmd/tools/vcover/main.v new file mode 100644 index 00000000000000..6d3c7ff954b4cb --- /dev/null +++ b/cmd/tools/vcover/main.v @@ -0,0 +1,208 @@ +// Copyright (c) 2024 Felipe Pena and Delyan Angelov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module main + +import os +import log +import flag +import json +import arrays +import encoding.csv + +// program options, storage etc +struct Context { +mut: + show_help bool + show_hotspots bool + show_percentages bool + show_test_files bool + use_absolute_paths bool + be_verbose bool + filter string + working_folder string + // + targets []string + meta map[string]MetaData // aggregated meta data, read from all .json files + all_lines_per_file map[string][]int // aggregated by load_meta + // + counters map[string]u64 // incremented by process_target, based on each .csv file + lines_per_file map[string]map[int]int // incremented by process_target, based on each .csv file + processed_points u64 +} + +const metadata_extension = '.json' +const vcounter_glob_pattern = 'vcounters_*.csv' + +fn (mut ctx Context) load_meta(folder string) { + for omfile in os.walk_ext(folder, metadata_extension) { + mfile := omfile.replace('\\', '/') + content := os.read_file(mfile) or { '' } + meta := os.file_name(mfile.replace(metadata_extension, '')) + data := json.decode(MetaData, content) or { + log.error('${@METHOD} failed to load ${mfile}') + continue + } + ctx.meta[meta] = data + mut lines_per_file := ctx.all_lines_per_file[data.file] + lines_per_file << data.points + ctx.all_lines_per_file[data.file] = arrays.distinct(lines_per_file) + } +} + +fn (mut ctx Context) post_process_all_metas() { + ctx.verbose('${@METHOD}') + for _, m in ctx.meta { + lines_per_file := ctx.all_lines_per_file[m.file] + for line in lines_per_file { + ctx.counters['${m.file}:${line}:'] = 0 + } + } +} + +fn (mut ctx Context) post_process_all_targets() { + ctx.verbose('${@METHOD}') + ctx.verbose('ctx.processed_points: ${ctx.processed_points}') +} + +fn (ctx &Context) verbose(msg string) { + if ctx.be_verbose { + log.info(msg) + } +} + +fn (mut ctx Context) process_target(tfile string) ! { + ctx.verbose('${@METHOD} ${tfile}') + mut reader := csv.new_reader_from_file(tfile)! + header := reader.read()! + if header != ['meta', 'point', 'hits'] { + return error('invalid header in .csv file') + } + for { + row := reader.read() or { break } + mut cline := CounterLine{ + meta: row[0] + point: row[1].int() + hits: row[2].u64() + } + m := ctx.meta[cline.meta] or { + ctx.verbose('> skipping invalid meta: ${cline.meta} in file: ${cline.file}, csvfile: ${tfile}') + continue + } + cline.file = m.file + cline.line = m.points[cline.point] or { + ctx.verbose('> skipping invalid point: ${cline.point} in file: ${cline.file}, meta: ${cline.meta}, csvfile: ${tfile}') + continue + } + ctx.counters['${cline.file}:${cline.line}:'] += cline.hits + mut lines := ctx.lines_per_file[cline.file].move() + lines[cline.line]++ + ctx.lines_per_file[cline.file] = lines.move() + // dump( ctx.lines_per_file[cline.meta][cline.point] ) + ctx.processed_points++ + } +} + +fn (mut ctx Context) show_report() ! { + filters := ctx.filter.split(',').filter(it != '') + if ctx.show_hotspots { + for location, hits in ctx.counters { + if filters.len > 0 { + if !filters.any(location.contains(it)) { + continue + } + } + mut final_path := normalize_path(location) + if !ctx.use_absolute_paths { + final_path = location.all_after_first('${ctx.working_folder}/') + } + println('${hits:-8} ${final_path}') + } + } + if ctx.show_percentages { + for file, lines in ctx.lines_per_file { + if !ctx.show_test_files { + if file.ends_with('_test.v') || file.ends_with('_test.c.v') { + continue + } + } + if filters.len > 0 { + if !filters.any(file.contains(it)) { + continue + } + } + total_lines := ctx.all_lines_per_file[file].len + executed_points := lines.len + coverage_percent := 100.0 * f64(executed_points) / f64(total_lines) + mut final_path := normalize_path(file) + if !ctx.use_absolute_paths { + final_path = file.all_after_first('${ctx.working_folder}/') + } + println('${final_path:-80s} | ${executed_points:6} | ${total_lines:6} | ${coverage_percent:6.2f}%') + } + } +} + +fn normalize_path(path string) string { + return path.replace(os.path_separator, '/') +} + +fn main() { + mut ctx := Context{} + ctx.working_folder = normalize_path(os.real_path(os.getwd())) + mut fp := flag.new_flag_parser(os.args#[1..]) + fp.application('v cover') + fp.version('0.0.2') + fp.description('Analyze & make reports, based on cover files, produced by running programs and tests, compiled with `-coverage folder/`') + fp.arguments_description('[folder1/ file2 ...]') + fp.skip_executable() + ctx.show_help = fp.bool('help', `h`, false, 'Show this help text.') + ctx.be_verbose = fp.bool('verbose', `v`, false, 'Be more verbose while processing the coverages.') + ctx.show_hotspots = fp.bool('hotspots', `H`, false, 'Show most frequently executed covered lines.') + ctx.show_percentages = fp.bool('percentages', `P`, true, 'Show coverage percentage per file.') + ctx.show_test_files = fp.bool('show_test_files', `S`, false, 'Show `_test.v` files as well (normally filtered).') + ctx.use_absolute_paths = fp.bool('absolute', `A`, false, 'Use absolute paths for all files, no matter the current folder. By default, files inside the current folder, are shown with a relative path.') + ctx.filter = fp.string('filter', `f`, '', 'Filter only the matching source path patterns.') + if ctx.show_help { + println(fp.usage()) + exit(0) + } + targets := fp.finalize() or { + log.error(fp.usage()) + exit(1) + } + ctx.verbose('Targets: ${targets}') + for t in targets { + if !os.exists(t) { + log.error('Skipping ${t}, since it does not exist') + continue + } + if os.is_dir(t) { + found_counter_files := os.walk_ext(t, '.csv') + if found_counter_files.len == 0 { + log.error('Skipping ${t}, since there are 0 ${vcounter_glob_pattern} files in it') + continue + } + for counterfile in found_counter_files { + ctx.targets << counterfile + ctx.load_meta(t) + } + } else { + ctx.targets << t + ctx.load_meta(os.dir(t)) + } + } + ctx.post_process_all_metas() + ctx.verbose('Final ctx.targets.len: ${ctx.targets.len}') + ctx.verbose('Final ctx.meta.len: ${ctx.meta.len}') + ctx.verbose('Final ctx.filter: ${ctx.filter}') + if ctx.targets.len == 0 { + log.error('0 cover targets') + exit(1) + } + for t in ctx.targets { + ctx.process_target(t)! + } + ctx.post_process_all_targets() + ctx.show_report()! +} diff --git a/cmd/tools/vcover/testdata/example1/abc.v b/cmd/tools/vcover/testdata/example1/abc.v new file mode 100644 index 00000000000000..cd2e92f3a36824 --- /dev/null +++ b/cmd/tools/vcover/testdata/example1/abc.v @@ -0,0 +1,313 @@ +module example1 + +fn abc01() int { + if true { + return 1 + } else { + return 2 + } +} + +fn abc02() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc03() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc04() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc05() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc06() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc07() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc08() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc09() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc10() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc11() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc12() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc13() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc14() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc15() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc16() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc17() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc18() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc19() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc20() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc21() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc22() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc23() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc24() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc25() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc26() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc27() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc28() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc29() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc30() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc31() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc32() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc33() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc34() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc35() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc36() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc37() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc38() int { + if true { + return 1 + } else { + return 0 + } +} + +fn abc39() int { + if true { + return 1 + } else { + return 0 + } +} diff --git a/cmd/tools/vcover/testdata/example1/internal_abc01_test.v b/cmd/tools/vcover/testdata/example1/internal_abc01_test.v new file mode 100644 index 00000000000000..3d4f09c4d729e5 --- /dev/null +++ b/cmd/tools/vcover/testdata/example1/internal_abc01_test.v @@ -0,0 +1,5 @@ +module example1 + +fn test_abc() { + abc01() +} diff --git a/cmd/tools/vcover/testdata/example1/internal_abc10_abc30_test.v b/cmd/tools/vcover/testdata/example1/internal_abc10_abc30_test.v new file mode 100644 index 00000000000000..b0252f8daa9ab5 --- /dev/null +++ b/cmd/tools/vcover/testdata/example1/internal_abc10_abc30_test.v @@ -0,0 +1,25 @@ +module example1 + +fn test_abc() { + abc10() + abc11() + abc12() + abc13() + abc14() + abc15() + abc16() + abc17() + abc18() + abc19() + abc20() + abc21() + abc22() + abc23() + abc24() + abc25() + abc26() + abc27() + abc28() + abc29() + abc30() +} diff --git a/cmd/tools/vcover/testdata/example1/internal_abc20_abc25_test.v b/cmd/tools/vcover/testdata/example1/internal_abc20_abc25_test.v new file mode 100644 index 00000000000000..c3ca1c646f5e0a --- /dev/null +++ b/cmd/tools/vcover/testdata/example1/internal_abc20_abc25_test.v @@ -0,0 +1,10 @@ +module example1 + +fn test_abc() { + abc20() + abc21() + abc22() + abc23() + abc24() + abc25() +} diff --git a/cmd/tools/vcover/testdata/example1/v.mod b/cmd/tools/vcover/testdata/example1/v.mod new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/cmd/tools/vcover/testdata/example2/condition.v b/cmd/tools/vcover/testdata/example2/condition.v new file mode 100644 index 00000000000000..900fd3ec932f5d --- /dev/null +++ b/cmd/tools/vcover/testdata/example2/condition.v @@ -0,0 +1,30 @@ +module example2 + +pub fn condition() int { + mut res := 0 + $if condition1 ? { + res += 1 + } + $if condition2 ? { + res += 2 + } + $if condition3 ? { + res += 4 + } + $if condition4 ? { + res += 8 + } + $if condition5 ? { + res += 16 + } + $if condition6 ? { + res += 32 + } + $if condition7 ? { + res += 64 + } + $if condition8 ? { + return 128 + } + return res +} diff --git a/cmd/tools/vcover/testdata/example2/condition_test.v b/cmd/tools/vcover/testdata/example2/condition_test.v new file mode 100644 index 00000000000000..07f20ac13464d1 --- /dev/null +++ b/cmd/tools/vcover/testdata/example2/condition_test.v @@ -0,0 +1,5 @@ +import example2 + +fn test_condition() { + println(example2.condition()) +} diff --git a/cmd/tools/vcover/testdata/example2/runtime_condition.v b/cmd/tools/vcover/testdata/example2/runtime_condition.v new file mode 100644 index 00000000000000..f877089b249f3b --- /dev/null +++ b/cmd/tools/vcover/testdata/example2/runtime_condition.v @@ -0,0 +1,36 @@ +module example2 + +import os + +pub fn runtime_condition() int { + mut res := 0 + branch := os.getenv('CONDITION') + if branch == '' { + return res + } + if branch.contains('1') { + res += 1 + } + if branch.contains('2') { + res += 2 + } + if branch.contains('3') { + res += 4 + } + if branch.contains('4') { + res += 8 + } + if branch.contains('5') { + res += 16 + } + if branch.contains('6') { + res += 32 + } + if branch.contains('7') { + res += 64 + } + if branch.contains('8') { + res += 128 + } + return res +} diff --git a/cmd/tools/vcover/testdata/example2/runtime_condition_test.v b/cmd/tools/vcover/testdata/example2/runtime_condition_test.v new file mode 100644 index 00000000000000..a798f2b9d11686 --- /dev/null +++ b/cmd/tools/vcover/testdata/example2/runtime_condition_test.v @@ -0,0 +1,5 @@ +import example2 + +fn test_runtime_condition() { + println(example2.runtime_condition()) +} diff --git a/cmd/tools/vcover/testdata/example2/v.mod b/cmd/tools/vcover/testdata/example2/v.mod new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/cmd/tools/vcover/testdata/simple/simple.v b/cmd/tools/vcover/testdata/simple/simple.v new file mode 100644 index 00000000000000..72e0c353d565d6 --- /dev/null +++ b/cmd/tools/vcover/testdata/simple/simple.v @@ -0,0 +1,26 @@ +module simple + +pub fn sum() int { + mut res := 0 + for i in 1 .. 5 { + res += i + } + return res +} + +pub fn mul() int { + mut res := 1 + for i in 1 .. 5 { + res *= i + } + // the lines here are just to introduce an asymmetry in the reported coverage lines + c := res * 2 + _ := c * 10 + return res +} + +pub const a_const = f() + +fn f() int { + return 50 // this should be executed always +} diff --git a/cmd/tools/vcover/testdata/simple/t1_test.v b/cmd/tools/vcover/testdata/simple/t1_test.v new file mode 100644 index 00000000000000..e4ce9691eb6009 --- /dev/null +++ b/cmd/tools/vcover/testdata/simple/t1_test.v @@ -0,0 +1,7 @@ +import simple + +fn test_sum() { + s := simple.sum() + assert s == 10 + println(s) +} diff --git a/cmd/tools/vcover/testdata/simple/t2_test.v b/cmd/tools/vcover/testdata/simple/t2_test.v new file mode 100644 index 00000000000000..c46c38fa6d8639 --- /dev/null +++ b/cmd/tools/vcover/testdata/simple/t2_test.v @@ -0,0 +1,7 @@ +import simple + +fn test_mul() { + m := simple.mul() + assert m == 24 + println(m) +} diff --git a/cmd/tools/vcover/testdata/simple/v.mod b/cmd/tools/vcover/testdata/simple/v.mod new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/cmd/v/v.v b/cmd/v/v.v index ea8fb7e4926d54..4946696d2dac6d 100644 --- a/cmd/v/v.v +++ b/cmd/v/v.v @@ -23,6 +23,7 @@ const external_tools = [ 'check-md', 'complete', 'compress', + 'cover', 'doc', 'doctor', 'fmt', diff --git a/vlib/v/gen/c/assert.v b/vlib/v/gen/c/assert.v index e0cfaee7e8230b..d7de1f69c42a7e 100644 --- a/vlib/v/gen/c/assert.v +++ b/vlib/v/gen/c/assert.v @@ -106,7 +106,7 @@ fn (mut g Gen) assert_subexpression_to_ctemp(expr ast.Expr, expr_type ast.Type) } fn (mut g Gen) gen_assert_postfailure_mode(node ast.AssertStmt) { - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) if g.pref.assert_failure_mode == .continues || g.fn_decl.attrs.any(it.name == 'assert_continues') { return diff --git a/vlib/v/gen/c/cgen.v b/vlib/v/gen/c/cgen.v index 4799e36d0f8aac..eebc54d07c9ce5 100644 --- a/vlib/v/gen/c/cgen.v +++ b/vlib/v/gen/c/cgen.v @@ -69,6 +69,7 @@ mut: auto_str_funcs strings.Builder // function bodies of all auto generated _str funcs dump_funcs strings.Builder // function bodies of all auto generated _str funcs pcs_declarations strings.Builder // -prof profile counter declarations for each function + cov_declarations strings.Builder // -cov coverage embedded_data strings.Builder // data to embed in the executable/binary shared_types strings.Builder // shared/lock types shared_functions strings.Builder // shared constructors @@ -119,6 +120,7 @@ mut: labeled_loops map[string]&ast.Stmt inner_loop &ast.Stmt = unsafe { nil } shareds map[int]string // types with hidden mutex for which decl has been emitted + coverage_files map[u64]&CoverageInfo inside_ternary int // ?: comma separated statements on a single line inside_map_postfix bool // inside map++/-- postfix expr inside_map_infix bool // inside map< 0 || g.out_results_forward.len > 0 { tail := g.type_definitions.cut_to(g.options_pos_forward) @@ -616,6 +632,10 @@ pub fn gen(files []&ast.File, mut table ast.Table, pref_ &pref.Preferences) (str b.writeln(fn_def) } } + if g.pref.is_coverage { + b.writeln('\n// V coverage:') + b.write_string(g.cov_declarations.str()) + } b.writeln('\n// end of V out') mut header := b.last_n(b.len) header = '#ifndef V_HEADER_FILE\n#define V_HEADER_FILE' + header @@ -654,6 +674,7 @@ fn cgen_process_one_file_cb(mut p pool.PoolProcessor, idx int, wid int) &Gen { auto_str_funcs: strings.new_builder(100) comptime_definitions: strings.new_builder(100) pcs_declarations: strings.new_builder(100) + cov_declarations: strings.new_builder(100) hotcode_definitions: strings.new_builder(100) embedded_data: strings.new_builder(1000) out_options_forward: strings.new_builder(100) @@ -724,6 +745,7 @@ pub fn (mut g Gen) free_builders() { g.dump_funcs.free() g.comptime_definitions.free() g.pcs_declarations.free() + g.cov_declarations.free() g.hotcode_definitions.free() g.embedded_data.free() g.shared_types.free() @@ -2049,7 +2071,7 @@ fn (mut g Gen) expr_with_tmp_var(expr ast.Expr, expr_typ ast.Type, ret_typ ast.T } @[inline] -fn (mut g Gen) write_v_source_line_info(pos token.Pos) { +fn (mut g Gen) write_v_source_line_info_pos(pos token.Pos) { if g.inside_ternary == 0 && g.pref.is_vlines && g.is_vlines_enabled { nline := pos.line_nr + 1 lineinfo := '\n#line ${nline} "${g.vlines_path}"' @@ -2060,6 +2082,30 @@ fn (mut g Gen) write_v_source_line_info(pos token.Pos) { } } +@[inline] +fn (mut g Gen) write_v_source_line_info(node ast.Node) { + g.write_v_source_line_info_pos(node.pos()) + if g.inside_ternary == 0 && g.pref.is_coverage + && node !in [ast.MatchBranch, ast.IfBranch, ast.InfixExpr] { + g.write_coverage_point(node.pos()) + } +} + +@[inline] +fn (mut g Gen) write_v_source_line_info_stmt(stmt ast.Stmt) { + g.write_v_source_line_info_pos(stmt.pos) + if g.inside_ternary == 0 && g.pref.is_coverage && !g.inside_for_c_stmt + && stmt !in [ast.FnDecl, ast.ForCStmt, ast.ForInStmt, ast.ForStmt] { + if stmt is ast.AssertStmt { + if stmt.expr !in [ast.InfixExpr, ast.MatchExpr] { + g.write_coverage_point(stmt.pos) + } + } else { + g.write_coverage_point(stmt.pos) + } + } +} + fn (mut g Gen) stmt(node ast.Stmt) { $if trace_cgen_stmt ? { ntype := typeof(node).replace('v.ast.', '') @@ -2075,19 +2121,19 @@ fn (mut g Gen) stmt(node ast.Stmt) { } match node { ast.AsmStmt { - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.asm_stmt(node) } ast.AssertStmt { - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.assert_stmt(node) } ast.AssignStmt { - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.assign_stmt(node) } ast.Block { - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) if node.is_unsafe { g.writeln('{ // Unsafe block') } else { @@ -2097,11 +2143,11 @@ fn (mut g Gen) stmt(node ast.Stmt) { g.writeln('}') } ast.BranchStmt { - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.branch_stmt(node) } ast.ConstDecl { - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.const_decl(node) } ast.ComptimeFor { @@ -2121,7 +2167,7 @@ fn (mut g Gen) stmt(node ast.Stmt) { g.enum_decl(node) } ast.ExprStmt { - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) // af := g.autofree && node.expr is ast.CallExpr && !g.is_builtin_mod // if af { // g.autofree_call_pregen(node.expr as ast.CallExpr) @@ -2161,7 +2207,7 @@ fn (mut g Gen) stmt(node ast.Stmt) { g.labeled_loops[node.label] = &node } } - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.for_c_stmt(node) g.branch_parent_pos = prev_branch_parent_pos g.labeled_loops.delete(node.label) @@ -2177,7 +2223,7 @@ fn (mut g Gen) stmt(node ast.Stmt) { g.labeled_loops[node.label] = &node } } - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.for_in_stmt(node) g.branch_parent_pos = prev_branch_parent_pos g.labeled_loops.delete(node.label) @@ -2193,7 +2239,7 @@ fn (mut g Gen) stmt(node ast.Stmt) { g.labeled_loops[node.label] = &node } } - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.for_stmt(node) g.branch_parent_pos = prev_branch_parent_pos g.labeled_loops.delete(node.label) @@ -2206,7 +2252,7 @@ fn (mut g Gen) stmt(node ast.Stmt) { g.writeln('${c_name(node.name)}: {}') } ast.GotoStmt { - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.writeln('goto ${c_name(node.name)};') } ast.HashStmt { @@ -5280,7 +5326,7 @@ fn (mut g Gen) branch_stmt(node ast.BranchStmt) { fn (mut g Gen) return_stmt(node ast.Return) { g.set_current_pos_as_last_stmt_pos() - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) g.inside_return = true defer { @@ -6424,6 +6470,10 @@ fn (mut g Gen) write_init_function() { if g.pref.use_coroutines { g.writeln('\tdelete_photon_work_pool();') } + if g.pref.is_coverage { + g.write_coverage_stats() + g.writeln('\tvprint_coverage_stats();') + } g.writeln('}') if g.pref.printfn_list.len > 0 && '_vcleanup' in g.pref.printfn_list { println(g.out.after(fn_vcleanup_start_pos)) diff --git a/vlib/v/gen/c/coverage.v b/vlib/v/gen/c/coverage.v new file mode 100644 index 00000000000000..37ae9dd0012758 --- /dev/null +++ b/vlib/v/gen/c/coverage.v @@ -0,0 +1,129 @@ +// Copyright (c) 2024 Felipe Pena and Delyan Angelov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module c + +import os +import rand +import v.ast +import v.token +import v.util.version +import hash + +// V coverage info +@[heap] +struct CoverageInfo { +mut: + idx int // index + points []u64 // code point line nr + file &ast.File = unsafe { nil } + fhash string // hash(fpath, build_options), prevents collisions for runs with different options, like `-os windows` or `-gc none`, which may affect the points, due to `$if ... {` etc + build_options string +} + +fn (mut g Gen) write_coverage_point(pos token.Pos) { + if g.unique_file_path_hash !in g.coverage_files { + build_options := g.pref.build_options.join(' ') + fhash := hash.sum64_string('${build_options}:${g.unique_file_path_hash}', 32).hex_full() + g.coverage_files[g.unique_file_path_hash] = &CoverageInfo{ + points: [] + file: g.file + fhash: fhash + build_options: build_options + } + } + if g.fn_decl != unsafe { nil } { + curr_line := u64(pos.line_nr) + mut curr_cov := unsafe { g.coverage_files[g.unique_file_path_hash] } + if curr_line !in curr_cov.points { + curr_cov.points << curr_line + } + stmt_str := g.go_before_last_stmt().trim_space() + g.empty_line = true + g.writeln('_v_cov[_v_cov_file_offset_${g.unique_file_path_hash}+${curr_cov.points.len - 1}]++;') + g.set_current_pos_as_last_stmt_pos() + g.write(stmt_str) + } +} + +fn (mut g Gen) write_coverage_stats() { + build_options := g.pref.build_options.join(' ') + coverage_dir := os.real_path(g.pref.coverage_dir).replace('\\', '/') + coverage_meta_folder := '${coverage_dir}/meta' + if !os.exists(coverage_meta_folder) { + os.mkdir_all(coverage_meta_folder) or {} + } + counter_ulid := rand.ulid() // rand.ulid provides a hash+timestamp, so that a collision is extremely unlikely + g.cov_declarations.writeln('') + g.cov_declarations.writeln('void vprint_coverage_stats() {') + g.cov_declarations.writeln('\tchar cov_filename[2048];') + covdir := cesc(coverage_dir) + g.cov_declarations.writeln('\tchar *cov_dir = "${covdir}";') + for _, mut cov in g.coverage_files { + metadata_coverage_fpath := '${coverage_meta_folder}/${cov.fhash}.json' + filepath := os.real_path(cov.file.path).replace('\\', '/') + if os.exists(metadata_coverage_fpath) { + continue + } + mut fmeta := os.create(metadata_coverage_fpath) or { continue } + fmeta.writeln('{') or { continue } + jfilepath := jesc(filepath) + jfhash := jesc(cov.fhash) + jversion := jesc(version.full_v_version(true)) + jboptions := jesc(cov.build_options) + fmeta.writeln(' "file": "${jfilepath}", "fhash": "${jfhash}",') or { continue } + fmeta.writeln(' "v_version": "${jversion}",') or { continue } + fmeta.writeln(' "build_options": "${jboptions}",') or { continue } + fmeta.writeln(' "npoints": ${cov.points.len},') or { continue } + fmeta.write_string(' "points": [ ') or { continue } + for idx, p in cov.points { + fmeta.write_string('${p + 1}') or { continue } + if idx < cov.points.len - 1 { + fmeta.write_string(',') or { continue } + } + } + fmeta.writeln(' ]') or { continue } + fmeta.writeln('}') or { continue } + fmeta.close() + } + g.cov_declarations.writeln('\tlong int secs = 0;') + g.cov_declarations.writeln('\tlong int nsecs = 0;') + g.cov_declarations.writeln('\t#if defined(_WIN32)') + g.cov_declarations.writeln('\tlong int ticks_passed = GetTickCount();') + g.cov_declarations.writeln('\nsecs = ticks_passed / 1000;') + g.cov_declarations.writeln('\nnsecs = (ticks_passed % 1000) * 1000000;') + g.cov_declarations.writeln('\t#endif') + g.cov_declarations.writeln('\t#if !defined(_WIN32)') + g.cov_declarations.writeln('\tstruct timespec ts;') + g.cov_declarations.writeln('\tclock_gettime(CLOCK_MONOTONIC, &ts);') + g.cov_declarations.writeln('\tsecs = ts.tv_sec;') + g.cov_declarations.writeln('\nsecs = ts.tv_nsec;') + g.cov_declarations.writeln('\t#endif') + g.cov_declarations.writeln('\tsnprintf(cov_filename, sizeof(cov_filename), "%s/vcounters_${counter_ulid}.%07ld.%09ld.csv", cov_dir, secs, nsecs);') + g.cov_declarations.writeln('\tFILE *fp = fopen(cov_filename, "wb+");') + cprefpath := cesc(os.real_path(g.pref.path)) + cboptions := cesc(build_options) + g.cov_declarations.writeln('\tfprintf(fp, "# path: ${cprefpath}\\n");') + g.cov_declarations.writeln('\tfprintf(fp, "# build_options: ${cboptions}\\n");') + g.cov_declarations.writeln('\tfprintf(fp, "meta,point,hits\\n");') + for k, cov in g.coverage_files { + nr_points := cov.points.len + g.cov_declarations.writeln('\t{') + g.cov_declarations.writeln('\t\tfor (int i = 0; i < ${nr_points}; ++i) {') + g.cov_declarations.writeln('\t\t\tif (_v_cov[_v_cov_file_offset_${k}+i]) {') + g.cov_declarations.writeln("\t\t\t\tfprintf(fp, \"%s,%d,%ld\\n\", \"${cov.fhash}\", i, _v_cov[_v_cov_file_offset_${k}+i]);") + g.cov_declarations.writeln('\t\t\t}') + g.cov_declarations.writeln('\t\t}') + g.cov_declarations.writeln('\t}') + } + g.cov_declarations.writeln('\tfclose(fp);') + g.cov_declarations.writeln('}') +} + +fn cesc(s string) string { + return cescape_nonascii(cestring(s)) +} + +fn jesc(s string) string { + return escape_quotes(s) +} diff --git a/vlib/v/gen/c/fn.v b/vlib/v/gen/c/fn.v index c437bc54a93c41..4032110be47f15 100644 --- a/vlib/v/gen/c/fn.v +++ b/vlib/v/gen/c/fn.v @@ -212,7 +212,7 @@ fn (mut g Gen) gen_fn_decl(node &ast.FnDecl, skip bool) { g.definitions.writeln(';') } - g.write_v_source_line_info(node.pos) + g.write_v_source_line_info_stmt(node) fn_attrs := g.write_fn_attrs(node.attrs) // Live is_livefn := node.attrs.contains('live') diff --git a/vlib/v/gen/c/match.v b/vlib/v/gen/c/match.v index cd393e6f548909..1b458831e8ee01 100644 --- a/vlib/v/gen/c/match.v +++ b/vlib/v/gen/c/match.v @@ -183,7 +183,7 @@ fn (mut g Gen) match_expr_sumtype(node ast.MatchExpr, is_expr bool, cond_var str g.write(' : ') } else { g.writeln('') - g.write_v_source_line_info(branch.pos) + g.write_v_source_line_info(branch) g.writeln('else {') } } else { @@ -191,7 +191,7 @@ fn (mut g Gen) match_expr_sumtype(node ast.MatchExpr, is_expr bool, cond_var str if use_ternary { g.write(' : ') } else { - g.write_v_source_line_info(branch.pos) + g.write_v_source_line_info(branch) g.write('else ') } } @@ -201,7 +201,7 @@ fn (mut g Gen) match_expr_sumtype(node ast.MatchExpr, is_expr bool, cond_var str if j == 0 && sumtype_index == 0 { g.empty_line = true } - g.write_v_source_line_info(branch.pos) + g.write_v_source_line_info(branch) g.write('if (') } g.write(cond_var) @@ -428,7 +428,7 @@ fn (mut g Gen) match_expr_classic(node ast.MatchExpr, is_expr bool, cond_var str g.write(' : ') } else { g.writeln('') - g.write_v_source_line_info(branch.pos) + g.write_v_source_line_info(branch) g.writeln('else {') } } @@ -438,7 +438,7 @@ fn (mut g Gen) match_expr_classic(node ast.MatchExpr, is_expr bool, cond_var str g.write(' : ') } else { g.writeln('') - g.write_v_source_line_info(branch.pos) + g.write_v_source_line_info(branch) g.write('else ') } } @@ -448,7 +448,7 @@ fn (mut g Gen) match_expr_classic(node ast.MatchExpr, is_expr bool, cond_var str if j == 0 { g.writeln('') } - g.write_v_source_line_info(branch.pos) + g.write_v_source_line_info(branch) g.write('if (') } for i, expr in branch.exprs { diff --git a/vlib/v/pref/pref.v b/vlib/v/pref/pref.v index 64e6ae68a4d626..161712752fbde5 100644 --- a/vlib/v/pref/pref.v +++ b/vlib/v/pref/pref.v @@ -117,9 +117,11 @@ pub mut: is_cstrict bool // turn on more C warnings; slightly slower is_callstack bool // turn on callstack registers on each call when v.debug is imported is_trace bool // turn on possibility to trace fn call where v.debug is imported + is_coverage bool // turn on code coverage stats eval_argument string // `println(2+2)` on `v -e "println(2+2)"`. Note that this source code, will be evaluated in vsh mode, so 'v -e 'println(ls(".")!)' is valid. test_runner string // can be 'simple' (fastest, but much less detailed), 'tap', 'normal' profile_file string // the profile results will be stored inside profile_file + coverage_dir string // the coverage files will be stored inside coverage_dir profile_no_inline bool // when true, [inline] functions would not be profiled profile_fns []string // when set, profiling will be off by default, but inside these functions (and what they call) it will be on. translated bool // `v translate doom.v` are we running V code translated from C? allow globals, ++ expressions, etc @@ -309,6 +311,10 @@ pub fn parse_args_and_show_errors(known_external_commands []string, args []strin if os.getenv('VNORUN') != '' { res.skip_running = true } + coverage_dir_from_env := os.getenv('VCOVDIR') + if coverage_dir_from_env != '' { + res.coverage_dir = coverage_dir_from_env + } /* $if macos || linux { res.use_cache = true res.skip_unused = true @@ -605,6 +611,10 @@ pub fn parse_args_and_show_errors(known_external_commands []string, args []strin res.build_options << '${arg} ${res.profile_file}' i++ } + '-cov', '-coverage' { + res.coverage_dir = cmdline.option(args[i..], arg, '-') + i++ + } '-profile-fns' { profile_fns := cmdline.option(args[i..], arg, '').split(',') if profile_fns.len > 0 { @@ -1070,6 +1080,10 @@ pub fn parse_args_and_show_errors(known_external_commands []string, args []strin if 'trace' in res.compile_defines_all { res.is_trace = true } + if res.coverage_dir != '' { + res.is_coverage = true + res.build_options << '-coverage ${res.coverage_dir}' + } // keep only the unique res.build_options: mut m := map[string]string{} for x in res.build_options {