piggly-nsd 2.3.3 → 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b53364d89c087bf4c2ce49ec451ece2cb1c17694bab9e765e034a05493ef3999
4
- data.tar.gz: a46eea287041eeaa4130b730dd409f1f35663e6fca5258e96c639b1f567eeb11
3
+ metadata.gz: 3aed72758cfe5ef63b8635586386845b2b3766a742cb398a9fc8121bd70e966f
4
+ data.tar.gz: 98e694a96ccff107b670de06d747b3a015a361a1e806c190e7bc6f9c36c0ec5f
5
5
  SHA512:
6
- metadata.gz: 1cfd022ed8e03c56d372911943bd7b662a6ff967f63bde7b09a01e010b5117bc41ad0f5c245a30e72139776a5eece380ab9d2d60175462555a874cfb008e73c8
7
- data.tar.gz: b8653bb06b92d31eee24e5c9a317fe912dc61e01d46fae649dadeb4610b831f4ad50199683996cddc756727124be68514fc0d919b372d80f70347851539d7751
6
+ metadata.gz: 889952ad1338856df2c539e199a18805236921b647add649937a9cfcec99578504915a000fd8174edeef13e485cab5cdda8a3ed03aa8e1a2273fe99bd5049235
7
+ data.tar.gz: 1de357aa82135b51d0c31b7515c102aa251bd489495f290aeaf71db302c2acc2bd19fee7dcc3410d7f1bf5e587b8852874994b02e61df6c03684114e5be53f9d
data/README.md CHANGED
@@ -51,6 +51,10 @@ these events and generates prettified source code that is annotated with coverag
51
51
  This fork continues the version numbering from the original piggly project:
52
52
  - **2.3.1** - Last version of original piggly by Kyle Putnam
53
53
  - **2.3.2** - NSD fork with UTF-8 encoding support and updated dependencies
54
+ - **2.3.3** - Fixed issues
55
+ - **2.3.4** - Added SonarQube integration and line coverage metrics:
56
+ - New `--sonar-report-path PATH` option to generate SonarQube generic coverage XML
57
+ - Added LINES and LINE COVERAGE columns to HTML reports
54
58
 
55
59
  ## How to Install
56
60
 
@@ -122,11 +126,19 @@ might run:
122
126
  etc.
123
127
 
124
128
  To build the coverage report, have piggly read that file in by executing `piggly report < messages.txt`,
125
- or `piggly report -f messages.txt`. You don't actually need the intermediate file, you can pipe your
129
+ or `piggly report -f messages.txt`. You don't actually need the intermediate file, you can pipe your
126
130
  test suite directly in like `ant test 2>&1 | piggly report`.
127
131
 
128
132
  Once the report is built you can open it in `piggly/reports/index.html`.
129
133
 
134
+ ### SonarQube Integration
135
+
136
+ To generate a SonarQube-compatible coverage report, use the `--sonar-report-path` (or `-x`) option:
137
+
138
+ $ piggly report -f messages.txt --sonar-report-path piggly/sonar/coverage.xml
139
+
140
+ This generates an XML file in SonarQube's [generic test coverage format](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/test-coverage/generic-test-data/).
141
+
130
142
  ## Running the Examples
131
143
 
132
144
  $ cd piggly
@@ -13,7 +13,7 @@ module Piggly
13
13
  class << Report
14
14
  def main(argv)
15
15
  require "pp"
16
- io, config = configure(argv)
16
+ io, config, sonar_path, html_report_requested = configure(argv)
17
17
 
18
18
  profile = Profile.new
19
19
  index = Dumper::Index.new(config)
@@ -33,12 +33,23 @@ module Piggly
33
33
 
34
34
  profile_procedures(config, procedures, profile)
35
35
  clear_coverage(config, profile)
36
-
37
36
  read_profile(config, io, profile)
38
37
  store_coverage(profile)
39
38
 
40
- create_index(config, index, procedures, profile)
41
- create_reports(config, procedures, profile)
39
+ # Generate HTML coverage report if requested
40
+ if html_report_requested
41
+ create_index(config, index, procedures, profile)
42
+ create_reports(config, procedures, profile)
43
+ end
44
+
45
+ # Generate Sonar coverage report if requested
46
+ if sonar_path
47
+ create_sonar_report(config, procedures, profile, sonar_path)
48
+ end
49
+
50
+ unless html_report_requested || sonar_path
51
+ puts "Warning: No report output specified. Use -o for HTML reports or -x for Sonar report."
52
+ end
42
53
  end
43
54
 
44
55
  # Adds the given procedures to Profile
@@ -118,14 +129,32 @@ module Piggly
118
129
  queue.execute
119
130
  end
120
131
 
132
+ # Create Sonar coverage XML report
133
+ #
134
+ def create_sonar_report(config, procedures, profile, output_path)
135
+ puts "creating Sonar coverage report"
136
+ reporter = Reporter::Sonar.new(config, profile, output_path)
137
+ path = reporter.report(procedures)
138
+ puts "Sonar coverage report written to: #{path}"
139
+ end
140
+
121
141
  def configure(argv, config = Config.new)
122
142
  io = $stdin
143
+ sonar_path = nil
144
+ html_report_requested = false
145
+
123
146
  p = OptionParser.new do |o|
124
147
  o.on("-t", "--dry-run", "only print the names of matching procedures", &o_dry_run(config))
125
148
  o.on("-s", "--select PATTERN", "select procedures matching PATTERN", &o_select(config))
126
149
  o.on("-r", "--reject PATTERN", "ignore procedures matching PATTERN", &o_reject(config))
127
150
  o.on("-c", "--cache-root PATH", "local cache directory", &o_cache_root(config))
128
- o.on("-o", "--report-root PATH", "report output directory", &o_report_root(config))
151
+ o.on("-o", "--report-root PATH", "report output directory") do |path|
152
+ config.report_root = path
153
+ html_report_requested = true
154
+ end
155
+ o.on("-x", "--sonar-report-path PATH", "generate Sonar coverage XML report at PATH") do |path|
156
+ sonar_path = path
157
+ end
129
158
  o.on("-a", "--accumulate", "accumulate data from the previous run", &o_accumulate(config))
130
159
  o.on("-V", "--version", "show version", &o_version(config))
131
160
  o.on("-h", "--help", "show this message") { abort o.to_s }
@@ -140,13 +169,18 @@ module Piggly
140
169
 
141
170
  begin
142
171
  p.parse! argv
172
+
173
+ unless html_report_requested || sonar_path
174
+ raise OptionParser::MissingArgument,
175
+ "at least one report type required: use -o for HTML reports or -x for Sonar report"
176
+ end
143
177
 
144
178
  if io.eql?($stdin) and $stdin.tty?
145
179
  raise OptionParser::MissingArgument,
146
180
  "stdin must be a pipe, or use --input PATH"
147
181
  end
148
182
 
149
- return io, config
183
+ return io, config, sonar_path, html_report_requested
150
184
  rescue OptionParser::InvalidOption,
151
185
  OptionParser::InvalidArgument,
152
186
  OptionParser::MissingArgument
@@ -0,0 +1,213 @@
1
+ module Piggly
2
+ module Compiler
3
+
4
+ #
5
+ # Calculates line-level coverage from tagged parse tree nodes.
6
+ #
7
+ class LineCoverage
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ # Calculate line coverage for a procedure
13
+ # @param procedure [Dumper::ReifiedProcedure, Dumper::SkeletonProcedure]
14
+ # @param profile [Profile]
15
+ # @return [Hash] { line_number => { covered: bool } }
16
+ def calculate(procedure, profile)
17
+ Parser.parser
18
+
19
+ compiler = TraceCompiler.new(@config)
20
+
21
+ # Let compile() handle staleness - it will recompile if needed
22
+ data = compiler.compile(procedure)
23
+
24
+ # Return empty if compilation failed (tree is nil)
25
+ tree = data[:tree]
26
+ return {} if tree.nil?
27
+
28
+ source = procedure.source(@config)
29
+
30
+ coverage = {}
31
+ traverse(tree, profile, source, coverage)
32
+ coverage
33
+ end
34
+
35
+ # Calculate summary statistics from coverage data
36
+ # @param coverage [Hash] line coverage data from calculate()
37
+ # @return [Hash] { count: Integer, percent: Float }
38
+ def summary(coverage)
39
+ return { count: 0, percent: nil } if coverage.empty?
40
+
41
+ total_lines = coverage.size
42
+ covered_lines = coverage.count { |_, v| v[:covered] }
43
+
44
+ {
45
+ count: total_lines,
46
+ percent: total_lines > 0 ? (covered_lines.to_f / total_lines * 100) : nil
47
+ }
48
+ end
49
+
50
+
51
+ protected
52
+
53
+ # Traverse the parse tree and collect coverage data for each line
54
+ # @param node [NodeClass] parse tree node
55
+ # @param profile [Profile] coverage profile with tags
56
+ # @param source [String] source code text
57
+ # @param coverage [Hash] accumulator for line coverage data
58
+ def traverse(node, profile, source, coverage)
59
+ if node.tagged?
60
+ begin
61
+ tag = profile[node.tag_id]
62
+
63
+ # Get line numbers for this node
64
+ start_line, end_line = node_line_range(node, source)
65
+
66
+ # Record coverage for each line spanned by this node
67
+ # Skip lines containing only structural keywords (begin, end, declare, etc.)
68
+ if start_line && end_line && start_line > 0 && end_line >= start_line
69
+ (start_line..end_line).each do |line|
70
+ next if excluded_line?(source, line)
71
+ record_line_coverage(coverage, line, tag)
72
+ end
73
+ end
74
+ rescue RuntimeError => e
75
+ # Skip nodes where tag lookup fails (expected for some nodes)
76
+ rescue => e
77
+ # Skip nodes with unexpected errors
78
+ end
79
+ end
80
+
81
+ # Recurse into child nodes
82
+ if node.respond_to?(:elements) && node.elements
83
+ node.elements.each { |child| traverse(child, profile, source, coverage) }
84
+ end
85
+
86
+ coverage
87
+ end
88
+
89
+ # Check if a source line should be excluded from coverage
90
+ # Lines containing only structural keywords or comments are not executable
91
+ # @param source [String] full source code
92
+ # @param line_number [Integer] 1-based line number
93
+ # @return [Boolean] true if line should be excluded
94
+ def excluded_line?(source, line_number)
95
+ lines = source.split("\n")
96
+ return true if line_number < 1 || line_number > lines.length
97
+
98
+ line_content = lines[line_number - 1].strip.downcase
99
+
100
+ # Exclude lines containing only structural keywords or comments
101
+ excluded_patterns = [
102
+ /\A\s*end\s*;\s*\z/i, # end;
103
+ /\A\s*begin\s*\z/i, # begin
104
+ /\A\s*declare\s*\z/i, # declare
105
+ /\A\s*\$\$\s*\z/, # $$ (dollar quoting)
106
+ /\A\s*\z/, # empty lines
107
+ /\A\s*--/, # single-line comments (-- ...)
108
+ /\A\s*\/\*.*\*\/\s*\z/ # single-line block comments (/* ... */)
109
+ ]
110
+
111
+ excluded_patterns.any? { |pattern| line_content =~ pattern }
112
+ end
113
+
114
+ # Calculate the line range for a node
115
+ # @param node [NodeClass] parse tree node
116
+ # @param source [String] source code text
117
+ # @return [Array<Integer, Integer>] start_line and end_line, or [nil, nil] if unable to calculate
118
+ def node_line_range(node, source)
119
+ return [nil, nil] unless node.respond_to?(:interval)
120
+ return [nil, nil] unless source.is_a?(String) && !source.empty?
121
+
122
+ interval = node.interval
123
+ return [nil, nil] unless interval.is_a?(Range)
124
+
125
+ start_pos = interval.first.to_i
126
+ return [nil, nil] if start_pos < 0
127
+
128
+ # Handle exclusive ranges (most common in Treetop)
129
+ if interval.exclude_end?
130
+ end_pos = [interval.end.to_i - 1, start_pos].max
131
+ else
132
+ end_pos = interval.end.to_i
133
+ end
134
+
135
+ # Clamp to source bounds
136
+ source_len = source.length
137
+ start_pos = [start_pos, source_len - 1].min if source_len > 0
138
+ end_pos = [end_pos, source_len - 1].min if source_len > 0
139
+
140
+ # Calculate line numbers by counting newlines
141
+ # Line numbers are 1-based
142
+ start_line = source[0...start_pos].count("\n") + 1
143
+ end_line = source[0..end_pos].count("\n") + 1
144
+
145
+ [start_line, end_line]
146
+ end
147
+
148
+ # Record coverage data for a specific line
149
+ # @param coverage [Hash] accumulator
150
+ # @param line [Integer] line number
151
+ # @param tag [Tags::AbstractTag] the tag for this node
152
+ def record_line_coverage(coverage, line, tag)
153
+ coverage[line] ||= {
154
+ covered: nil, # nil = no block/loop tags yet, will be determined by branches if any
155
+ has_block_or_loop: false
156
+ }
157
+
158
+ case tag.type
159
+ when :block
160
+ # Block tags affect line coverage - line is covered if block was executed
161
+ coverage[line][:has_block_or_loop] = true
162
+ if coverage[line][:covered].nil?
163
+ coverage[line][:covered] = tag.complete?
164
+ else
165
+ coverage[line][:covered] = coverage[line][:covered] && tag.complete?
166
+ end
167
+
168
+ when :loop
169
+ # A loop is covered if it was executed at least once (any iteration pattern)
170
+ coverage[line][:has_block_or_loop] = true
171
+ loop_executed = loop_was_executed?(tag)
172
+ if coverage[line][:covered].nil?
173
+ coverage[line][:covered] = loop_executed
174
+ else
175
+ coverage[line][:covered] = coverage[line][:covered] && loop_executed
176
+ end
177
+
178
+ when :branch
179
+ # For lines with only branches, mark as covered if at least one branch was taken
180
+ unless coverage[line][:has_block_or_loop]
181
+ if tag.is_a?(Tags::ConditionalBranchTag)
182
+ branch_taken = tag.true || tag.false
183
+ if coverage[line][:covered].nil?
184
+ coverage[line][:covered] = branch_taken
185
+ else
186
+ coverage[line][:covered] = coverage[line][:covered] || branch_taken
187
+ end
188
+ else
189
+ # Unconditional branches (return, exit, etc.)
190
+ if coverage[line][:covered].nil?
191
+ coverage[line][:covered] = tag.complete?
192
+ else
193
+ coverage[line][:covered] = coverage[line][:covered] || tag.complete?
194
+ end
195
+ end
196
+ end
197
+ end
198
+
199
+ # Ensure covered has a boolean value (default to false if still nil)
200
+ coverage[line][:covered] = false if coverage[line][:covered].nil?
201
+ end
202
+
203
+ # Check if a loop tag indicates the loop was executed at least once
204
+ # For Sonar, partial loop coverage counts as covered
205
+ # @param tag [Tags::AbstractLoopTag] the loop tag
206
+ # @return [Boolean] true if loop was executed (any iteration pattern)
207
+ def loop_was_executed?(tag)
208
+ tag.pass || tag.once || tag.twice || tag.ends
209
+ end
210
+ end
211
+
212
+ end
213
+ end
@@ -3,5 +3,6 @@ module Piggly
3
3
  autoload :CacheDir, "piggly/compiler/cache_dir"
4
4
  autoload :TraceCompiler, "piggly/compiler/trace_compiler"
5
5
  autoload :CoverageReport, "piggly/compiler/coverage_report"
6
+ autoload :LineCoverage, "piggly/compiler/line_coverage"
6
7
  end
7
8
  end
@@ -31,13 +31,15 @@ module Piggly
31
31
 
32
32
  private
33
33
 
34
- def aggregate(label, summary)
34
+ def aggregate(label, summary, line_summary = nil)
35
35
  tag :p, label, :class => "summary"
36
36
  tag :table, :class => "summary" do
37
37
  tag :tr do
38
+ tag :th, "Lines"
38
39
  tag :th, "Blocks"
39
40
  tag :th, "Loops"
40
41
  tag :th, "Branches"
42
+ tag :th, "Line Coverage"
41
43
  tag :th, "Block Coverage"
42
44
  tag :th, "Loop Coverage"
43
45
  tag :th, "Branch Coverage"
@@ -49,13 +51,27 @@ module Piggly
49
51
  tag(:td, :class => "count") { tag :span, -1 }
50
52
  tag(:td, :class => "count") { tag :span, -1 }
51
53
  tag(:td, :class => "count") { tag :span, -1 }
54
+ tag(:td, :class => "count") { tag :span, -1 }
55
+ tag(:td, :class => "pct") { tag :span, -1 }
52
56
  tag(:td, :class => "pct") { tag :span, -1 }
53
57
  tag(:td, :class => "pct") { tag :span, -1 }
54
58
  tag(:td, :class => "pct") { tag :span, -1 }
55
59
  else
60
+ # Line coverage (from LineCoverage module)
61
+ if line_summary
62
+ tag(:td, (line_summary[:count] || 0), :class => "count")
63
+ else
64
+ tag(:td, :class => "count") { tag :span, -1 }
65
+ end
56
66
  tag(:td, (summary[:block][:count] || 0), :class => "count")
57
67
  tag(:td, (summary[:loop][:count] || 0), :class => "count")
58
68
  tag(:td, (summary[:branch][:count] || 0), :class => "count")
69
+ # Line coverage percentage
70
+ if line_summary
71
+ tag(:td, :class => "pct") { percent(line_summary[:percent]) }
72
+ else
73
+ tag(:td, :class => "pct") { tag :span, -1 }
74
+ end
59
75
  tag(:td, :class => "pct") { percent(summary[:block][:percent]) }
60
76
  tag(:td, :class => "pct") { percent(summary[:loop][:percent]) }
61
77
  tag(:td, :class => "pct") { percent(summary[:branch][:percent]) }
@@ -5,11 +5,16 @@ module Piggly
5
5
 
6
6
  def initialize(config, profile)
7
7
  @config, @profile = config, profile
8
+ @line_coverage = Compiler::LineCoverage.new(config)
8
9
  end
9
10
 
10
11
  def report(procedures, index)
11
12
  io = File.open("#{report_path}/index.html", "w")
12
13
 
14
+ # Calculate line coverage for all procedures
15
+ all_line_coverage = calculate_all_line_coverage(procedures)
16
+ overall_line_summary = @line_coverage.summary(all_line_coverage)
17
+
13
18
  html(io) do
14
19
  tag :html do
15
20
  tag :head do
@@ -20,7 +25,7 @@ module Piggly
20
25
  end
21
26
 
22
27
  tag :body do
23
- aggregate("PL/pgSQL Coverage Summary", @profile.summary)
28
+ aggregate("PL/pgSQL Coverage Summary", @profile.summary, overall_line_summary)
24
29
  table(procedures.sort_by{|p| index.label(p) }, index)
25
30
  timestamp
26
31
  end
@@ -32,14 +37,38 @@ module Piggly
32
37
 
33
38
  private
34
39
 
40
+ # Calculate combined line coverage summary for all procedures
41
+ # Returns aggregated coverage data that can be passed to @line_coverage.summary()
42
+ def calculate_all_line_coverage(procedures)
43
+ all_coverage = {}
44
+ next_key = 1 # Use sequential keys to avoid collisions
45
+
46
+ procedures.each do |procedure|
47
+ begin
48
+ coverage = @line_coverage.calculate(procedure, @profile)
49
+ # Add each line's coverage data with a unique key
50
+ # Keys don't need to represent actual line numbers for summary calculation
51
+ coverage.each do |_line, data|
52
+ all_coverage[next_key] = data
53
+ next_key += 1
54
+ end
55
+ rescue => e
56
+ $stderr.puts "Index: ERROR calculating coverage for #{procedure.name}: #{e.class}: #{e.message}"
57
+ end
58
+ end
59
+ all_coverage
60
+ end
61
+
35
62
  def table(procedures, index)
36
63
  tag :div, :class => "table-wrapper" do
37
64
  tag :table, :class => "summary sortable" do
38
65
  tag :tr do
39
66
  tag :th, "Procedure"
67
+ tag :th, "Lines"
40
68
  tag :th, "Blocks"
41
69
  tag :th, "Loops"
42
70
  tag :th, "Branches"
71
+ tag :th, "Line Coverage"
43
72
  tag :th, "Block Coverage"
44
73
  tag :th, "Loop Coverage"
45
74
  tag :th, "Branch Coverage"
@@ -50,6 +79,15 @@ module Piggly
50
79
  row = k.modulo(2) == 0 ? "even" : "odd"
51
80
  label = index.label(procedure)
52
81
 
82
+ # Calculate line coverage for this procedure
83
+ line_summary = nil
84
+ begin
85
+ coverage = @line_coverage.calculate(procedure, @profile)
86
+ line_summary = @line_coverage.summary(coverage)
87
+ rescue => e
88
+ # Skip if can't calculate
89
+ end
90
+
53
91
  tag :tr, :class => row do
54
92
  unless summary.include?(:block) or summary.include?(:loop) or summary.include?(:branch)
55
93
  # Parser couldn't parse this file
@@ -57,14 +95,18 @@ module Piggly
57
95
  tag(:td, :class => "count") { tag :span, -1 }
58
96
  tag(:td, :class => "count") { tag :span, -1 }
59
97
  tag(:td, :class => "count") { tag :span, -1 }
98
+ tag(:td, :class => "count") { tag :span, -1 }
99
+ tag(:td, :class => "pct") { tag :span, -1 }
60
100
  tag(:td, :class => "pct") { tag :span, -1 }
61
101
  tag(:td, :class => "pct") { tag :span, -1 }
62
102
  tag(:td, :class => "pct") { tag :span, -1 }
63
103
  else
64
104
  tag(:td, :class => "file") { tag :a, label, :href => procedure.identifier + ".html" }
105
+ tag :td, (line_summary ? line_summary[:count] : 0), :class => "count"
65
106
  tag :td, (summary[:block][:count] || 0), :class => "count"
66
107
  tag :td, (summary[:loop][:count] || 0), :class => "count"
67
108
  tag :td, (summary[:branch][:count] || 0), :class => "count"
109
+ tag(:td, :class => "pct") { percent(line_summary ? line_summary[:percent] : nil) }
68
110
  tag(:td, :class => "pct") { percent(summary[:block][:percent]) }
69
111
  tag(:td, :class => "pct") { percent(summary[:loop][:percent]) }
70
112
  tag(:td, :class => "pct") { percent(summary[:branch][:percent]) }
@@ -5,6 +5,7 @@ module Piggly
5
5
 
6
6
  def initialize(config, profile)
7
7
  @config, @profile = config, profile
8
+ @line_coverage = Compiler::LineCoverage.new(config)
8
9
  end
9
10
 
10
11
  def report(procedure)
@@ -14,6 +15,15 @@ module Piggly
14
15
  compiler = Compiler::CoverageReport.new(@config)
15
16
  data = compiler.compile(procedure, @profile)
16
17
 
18
+ # Calculate line coverage for this procedure
19
+ line_summary = nil
20
+ begin
21
+ coverage = @line_coverage.calculate(procedure, @profile)
22
+ line_summary = @line_coverage.summary(coverage)
23
+ rescue => e
24
+ # Skip if can't calculate
25
+ end
26
+
17
27
  html(io) do
18
28
  tag :html, :xmlns => "http://www.w3.org/1999/xhtml" do
19
29
  tag :head do
@@ -25,7 +35,7 @@ module Piggly
25
35
 
26
36
  tag :body do
27
37
  tag :div, :class => "header" do
28
- aggregate(procedure.name, @profile.summary(procedure))
38
+ aggregate(procedure.name, @profile.summary(procedure), line_summary)
29
39
  end
30
40
 
31
41
  tag :div, :class => "container" do
@@ -421,15 +421,15 @@ table.full td.covered {
421
421
  width: 100% !important;
422
422
  }
423
423
 
424
- /* SYNTAX HIGHLIGHTING - Modern color scheme */
425
- .tI { color: #475569; } /* identifier - slate-600 */
426
- .tD { color: #7c3aed; font-style: italic; } /* data type - violet-600 */
427
- .tK { color: #dc2626; font-weight: 500; } /* keyword - red-600 */
428
- .tC { color: #6366f1; font-style: italic; } /* comment - indigo-500 */
429
- .tQ { color: #059669; font-style: italic; } /* sql statement - emerald-600 */
430
- .tS { color: #10b981; } /* string literal - emerald-500 */
431
- .tL { color: #ea580c; font-style: italic; } /* label - orange-600 */
432
- .tM { color: var(--color-text-secondary); } /* dollar quote marker */
424
+ /* SYNTAX HIGHLIGHTING - Inherit coverage colors, except comments */
425
+ .tI { color: inherit; } /* identifier */
426
+ .tD { color: inherit; font-style: italic; } /* data type */
427
+ .tK { color: inherit; font-weight: 500; } /* keyword */
428
+ .tC { color: var(--color-text); font-style: italic; } /* comment */
429
+ .tQ { color: inherit; font-style: italic; } /* sql statement */
430
+ .tS { color: inherit; } /* string literal */
431
+ .tL { color: inherit; font-style: italic; } /* label */
432
+ .tM { color: inherit; } /* dollar quote marker */
433
433
 
434
434
  /* CODE BLOCKS */
435
435
  .b {
@@ -460,18 +460,23 @@ table.full td.covered {
460
460
  }
461
461
 
462
462
  .c1 {
463
- color: var(--color-text);
463
+ color: var(--color-success);
464
464
  }
465
465
 
466
466
  /* LOOP COVERAGE */
467
- .l0000, .l0001, .l0010, .l0100, .l0011, .l0101, .l0110, .l0111,
467
+ .l0000 {
468
+ font-weight: 600;
469
+ color: var(--color-danger); /* red - never evaluated */
470
+ }
471
+
472
+ .l0001, .l0010, .l0100, .l0011, .l0101, .l0110, .l0111,
468
473
  .l1000, .l1001, .l1010, .l1100, .l1011, .l1101, .l1110 {
469
474
  font-weight: 600;
470
- color: var(--color-warning);
475
+ color: var(--color-warning); /* orange - partial coverage */
471
476
  }
472
477
 
473
478
  .l1111 {
474
- color: var(--color-text);
479
+ color: var(--color-success); /* green - full coverage */
475
480
  }
476
481
 
477
482
  /* BRANCH DECISIONS */
@@ -480,18 +485,14 @@ table.full td.covered {
480
485
  color: var(--color-danger);
481
486
  }
482
487
 
483
- .b01 {
484
- font-weight: 600;
485
- color: #10b981;
486
- }
487
-
488
+ .b01,
488
489
  .b10 {
489
490
  font-weight: 600;
490
- color: #f59e0b;
491
+ color: var(--color-warning);
491
492
  }
492
493
 
493
494
  .b11 {
494
- color: var(--color-text);
495
+ color: var(--color-success);
495
496
  }
496
497
 
497
498
  /* SCROLLBAR STYLING (for webkit browsers) */
@@ -0,0 +1,94 @@
1
+ module Piggly
2
+ module Reporter
3
+
4
+ #
5
+ # Generates SonarQube generic test coverage XML format.
6
+ #
7
+ # Format specification:
8
+ # https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/test-coverage/generic-test-data/
9
+ #
10
+ class Sonar < Base
11
+
12
+ def initialize(config, profile, output_path = nil)
13
+ @config = config
14
+ @profile = profile
15
+ @output_path = output_path || File.join(@config.report_root, "sonar-coverage.xml")
16
+ @line_coverage = Compiler::LineCoverage.new(config)
17
+ end
18
+
19
+ # Generate Sonar coverage report for all procedures
20
+ # @param procedures [Array<Dumper::ReifiedProcedure>] list of procedures
21
+ def report(procedures)
22
+ FileUtils.makedirs(File.dirname(@output_path))
23
+
24
+ File.open(@output_path, "w:UTF-8") do |io|
25
+ io.puts '<?xml version="1.0" encoding="UTF-8"?>'
26
+ io.puts '<coverage version="1">'
27
+
28
+ procedures.each do |procedure|
29
+ begin
30
+ write_procedure_coverage(io, procedure)
31
+ rescue => e
32
+ # Skip procedures that can't be processed
33
+ $stderr.puts "Warning: Could not generate Sonar coverage for #{procedure.name}: #{e.message}"
34
+ end
35
+ end
36
+
37
+ io.puts '</coverage>'
38
+ end
39
+
40
+ @output_path
41
+ end
42
+
43
+ private
44
+
45
+ # Write coverage data for a single procedure
46
+ def write_procedure_coverage(io, procedure)
47
+ coverage = @line_coverage.calculate(procedure, @profile)
48
+ return if coverage.empty?
49
+
50
+ # Get the source file path and convert to relative path
51
+ absolute_path = procedure.source_path(@config)
52
+ source_path = make_relative_path(absolute_path)
53
+
54
+ io.puts " <file path=\"#{escape_xml(source_path)}\">"
55
+
56
+ # Sort lines and output coverage data
57
+ coverage.keys.sort.each do |line|
58
+ line_data = coverage[line]
59
+ write_line_coverage(io, line, line_data)
60
+ end
61
+
62
+ io.puts " </file>"
63
+ end
64
+
65
+ # Write coverage data for a single line
66
+ def write_line_coverage(io, line_number, line_data)
67
+ attrs = []
68
+ attrs << "lineNumber=\"#{line_number}\""
69
+ attrs << "covered=\"#{line_data[:covered]}\""
70
+
71
+ io.puts " <lineToCover #{attrs.join(' ')}/>"
72
+ end
73
+
74
+ # Escape special XML characters
75
+ def escape_xml(text)
76
+ text.to_s
77
+ .gsub("&", "&amp;")
78
+ .gsub("<", "&lt;")
79
+ .gsub(">", "&gt;")
80
+ .gsub("\"", "&quot;")
81
+ .gsub("'", "&apos;")
82
+ end
83
+
84
+ # Convert absolute path to relative path from project root for cross-platform compatibility
85
+ def make_relative_path(absolute_path)
86
+ project_root = File.dirname(File.dirname(@config.cache_root))
87
+
88
+ relative = absolute_path.sub(/^#{Regexp.escape(project_root)}[\/\\]?/, '')
89
+ relative.gsub('\\', '/')
90
+ end
91
+ end
92
+
93
+ end
94
+ end
@@ -4,5 +4,6 @@ module Piggly
4
4
  autoload :Base, "piggly/reporter/base"
5
5
  autoload :Index, "piggly/reporter/index"
6
6
  autoload :Procedure, "piggly/reporter/procedure"
7
+ autoload :Sonar, "piggly/reporter/sonar"
7
8
  end
8
9
  end
data/lib/piggly/tags.rb CHANGED
@@ -175,7 +175,7 @@ module Piggly
175
175
  end
176
176
 
177
177
  def style
178
- "l#{[@pass, @once, @twice, @ends].map{|b| b ? 1 : 0}}"
178
+ "l#{[@pass, @once, @twice, @ends].map{|b| b ? 1 : 0}.join}"
179
179
  end
180
180
 
181
181
  def to_f
@@ -2,9 +2,9 @@ module Piggly
2
2
  module VERSION
3
3
  MAJOR = 2
4
4
  MINOR = 3
5
- TINY = 3
5
+ TINY = 4
6
6
 
7
- RELEASE_DATE = "2025-12-12"
7
+ RELEASE_DATE = "2026-02-12"
8
8
  end
9
9
 
10
10
  class << VERSION
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: piggly-nsd
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.3
4
+ version: 2.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kvle Putnam
@@ -56,6 +56,7 @@ files:
56
56
  - lib/piggly/compiler.rb
57
57
  - lib/piggly/compiler/cache_dir.rb
58
58
  - lib/piggly/compiler/coverage_report.rb
59
+ - lib/piggly/compiler/line_coverage.rb
59
60
  - lib/piggly/compiler/trace_compiler.rb
60
61
  - lib/piggly/config.rb
61
62
  - lib/piggly/dumper.rb
@@ -79,6 +80,7 @@ files:
79
80
  - lib/piggly/reporter/resources/highlight.js
80
81
  - lib/piggly/reporter/resources/piggly.css
81
82
  - lib/piggly/reporter/resources/sortable.js
83
+ - lib/piggly/reporter/sonar.rb
82
84
  - lib/piggly/tags.rb
83
85
  - lib/piggly/task.rb
84
86
  - lib/piggly/util.rb