diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d72435b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +test.txt +test2.txt +__pycache__ + diff --git a/gcovr-templates/html/base.html b/gcovr-templates/html/base.html index 2ccb8ab..7be4e89 100644 --- a/gcovr-templates/html/base.html +++ b/gcovr-templates/html/base.html @@ -128,9 +128,6 @@ - diff --git a/gcovr-templates/html/gcovr.js b/gcovr-templates/html/gcovr.js index 6a843f6..26ae1f2 100644 --- a/gcovr-templates/html/gcovr.js +++ b/gcovr-templates/html/gcovr.js @@ -78,6 +78,22 @@ var fragment = document.createDocumentFragment(); var matchedSegments = []; + // Fill an element with the segments of a (possibly joined) name like + // "boost/url", rendering "boost", a separator, "url". Used so a joined + // directory shows its segments inline yet remains one hyperlink target. + function appendSegments(parentEl, name) { + var segments = name.split('/'); + for (var k = 0; k < segments.length; k++) { + if (k > 0) { + var inner = document.createElement('span'); + inner.className = 'separator'; + inner.textContent = '/'; + parentEl.appendChild(inner); + } + parentEl.appendChild(document.createTextNode(segments[k])); + } + } + for (var i = 0; i < treePath.length; i++) { var node = treePath[i]; var isLast = (i === treePath.length - 1); @@ -94,12 +110,12 @@ if (node.link && !isLast) { var a = document.createElement('a'); a.href = node.link; - a.textContent = node.name; + appendSegments(a, node.name); fragment.appendChild(a); } else { var span = document.createElement('span'); span.className = 'current-file'; - span.textContent = node.name; + appendSegments(span, node.name); fragment.appendChild(span); } } @@ -398,55 +414,13 @@ var treeContainer = document.getElementById('file-tree'); if (!treeContainer) return; - // Check for embedded tree data first (works for local file:// access) - if (window.GCOVR_TREE_DATA) { - window.GCOVR_TREE_DATA = normalizeTree(window.GCOVR_TREE_DATA); - deduplicateTree(window.GCOVR_TREE_DATA); - collapseSingleChildDirs(window.GCOVR_TREE_DATA); + // Tree data is produced already-normalized and already-sorted by the + // upstream tooling (Python's gcovr_build_tree.py or gcovr itself), so + // no normalize/sort pass is needed here. We still dedupe + join chains. deduplicateTree(window.GCOVR_TREE_DATA); + joinSingleChildDirs(window.GCOVR_TREE_DATA); + sortTree(window.GCOVR_TREE_DATA); renderTree(treeContainer, window.GCOVR_TREE_DATA); - return; - } - - // Fallback: try to load tree.json for full hierarchy - fetch('tree.json') - .then(function(response) { - if (!response.ok) throw new Error('No tree.json'); - return response.json(); - }) - .then(function(tree) { - window.GCOVR_TREE_DATA = normalizeTree(tree); - deduplicateTree(window.GCOVR_TREE_DATA); - collapseSingleChildDirs(window.GCOVR_TREE_DATA); - deduplicateTree(window.GCOVR_TREE_DATA); - renderTree(treeContainer, window.GCOVR_TREE_DATA); - // Re-run dependent init now that the tree exists - initNavOverride(); - initBreadcrumbs(); - initSearch(); - }) - .catch(function(err) { - console.log('tree.json not found, using static sidebar'); - // Keep existing static content from Jinja template - }); - } - - // cspell:ignore capy - // Collapse single-child directory chains: if a directory has exactly - // one child and that child is also a directory, absorb the grandchildren. - // e.g. include > boost > capy > [items] becomes include > [items] - function collapseSingleChildDirs(nodes) { - for (var i = 0; i < nodes.length; i++) { - var node = nodes[i]; - if (!node.isDirectory || !node.children) continue; - while (node.children.length === 1 && node.children[0].isDirectory && - node.children[0].children && node.children[0].children.length > 0) { - var child = node.children[0]; - if (!node.link && child.link) node.link = child.link; - node.children = child.children; - } - collapseSingleChildDirs(node.children); - } } // Deduplicate tree: when a node has a child with the same name @@ -475,72 +449,62 @@ } } - // Normalize tree: expand multi-segment node names (e.g. "capy/buffers") - // into proper nested directory structures so the tree and breadcrumbs - // display correctly. - function normalizeTree(nodes) { - if (!nodes || nodes.length === 0) return nodes; - - var groups = {}; - var order = []; + // Re-sort the tree: directories first, then files, alphabetically within + // each group. Python already sorts each level, but normalizeTree creates + // synthetic directory nodes from multi-segment FILE entries (e.g. a deep + // chain like subdir1/subdir2/subdir3/file.hpp that gcovr itself collapsed). + // Those synthetic dirs end up wherever the originating file landed in the + // Python sort — i.e. in the file bucket — so without this pass they appear + // mixed in with the files instead of at the top with the other directories. + function sortTree(nodes) { + if (!nodes || nodes.length === 0) return; + nodes.sort(function(a, b) { + var aIsDir = a.isDirectory || (a.children && a.children.length > 0); + var bIsDir = b.isDirectory || (b.children && b.children.length > 0); + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + var aName = (a.name || '').toLowerCase(); + var bName = (b.name || '').toLowerCase(); + return aName.localeCompare(bName); + }); + for (var i = 0; i < nodes.length; i++) { + if (nodes[i].children) sortTree(nodes[i].children); + } + } + // Join chains of single-child directories into one sidebar entry. + // If a directory contains nothing but one child directory, the two are + // merged: the name becomes "parent/child", and the link + stats are taken + // from the (deepest) child. The grandchildren become the new children. + // Repeats until the chain ends (multiple children, or a file appears). + // Result: e.g. include > boost > url > [files] becomes include/boost/url + // as a single entry whose click navigates straight to the url directory. + function joinSingleChildDirs(nodes) { + if (!nodes) return; + var statKeys = ['linesTotal', 'linesExec', 'linesCoverage', 'linesClass', + 'functionsCoverage', 'functionsClass', + 'branchesCoverage', 'branchesClass']; for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; - var slashIdx = node.name.indexOf('/'); - - if (slashIdx === -1) { - // Simple name — add directly or merge with existing group - if (groups[node.name]) { - var existing = groups[node.name]; - if (node.link) existing.link = node.link; - if (node.coverage) existing.coverage = node.coverage; - if (node.coverageClass) existing.coverageClass = node.coverageClass; - if (node.children && node.children.length > 0) { - existing.children = (existing.children || []).concat(node.children); - } - } else { - var copy = {}; - for (var key in node) { - if (node.hasOwnProperty(key)) copy[key] = node[key]; + if (!node.isDirectory || !node.children) continue; + while (node.children.length === 1 && node.children[0].isDirectory) { + var child = node.children[0]; + node.name = node.name + '/' + child.name; + // The joined entry represents the deepest directory for clicks + // and for the coverage stats shown next to it. + if (child.link) node.link = child.link; + if (child.coverage) node.coverage = child.coverage; + if (child.coverageClass) node.coverageClass = child.coverageClass; + for (var k = 0; k < statKeys.length; k++) { + var key = statKeys[k]; + if (child[key] !== undefined && child[key] !== '') { + node[key] = child[key]; } - groups[node.name] = copy; - order.push(node.name); - } - } else { - // Multi-segment name — split on first '/' and group - var prefix = node.name.substring(0, slashIdx); - var rest = node.name.substring(slashIdx + 1); - - if (!groups[prefix]) { - groups[prefix] = { - name: prefix, - isDirectory: true, - children: [] - }; - order.push(prefix); - } - if (!groups[prefix].children) groups[prefix].children = []; - - // Create child node with remaining path as name - var childNode = {}; - for (var key in node) { - if (node.hasOwnProperty(key)) childNode[key] = node[key]; } - childNode.name = rest; - groups[prefix].children.push(childNode); + node.children = child.children || []; } + joinSingleChildDirs(node.children); } - - // Build result with recursive normalization - var result = []; - for (var i = 0; i < order.length; i++) { - var node = groups[order[i]]; - if (node.children && node.children.length > 0) { - node.children = normalizeTree(node.children); - } - result.push(node); - } - return result; } // Save expanded folder paths to localStorage @@ -697,8 +661,9 @@ header.appendChild(icon); // Label (with link if available) - // Clean the display name to remove relative path prefixes like '../../../' - var displayName = getDisplayName(item.name); + // Use the full cleaned name so joined-dir entries like "boost/url" + // display as a single multi-segment label in the sidebar. + var displayName = cleanPathName(item.name); var tooltipText = cleanPathName(item.fullPath || item.name); var label = document.createElement('span'); label.className = 'tree-label'; @@ -2191,3 +2156,5 @@ } })(); + +window.GCOVR_TREE_DATA = {{ GCOVR_TREE_DATA | default([]) | tojson(2) | safe }}; diff --git a/scripts/fix_paths.py b/scripts/fix_paths.py index c4ddf86..b34339d 100644 --- a/scripts/fix_paths.py +++ b/scripts/fix_paths.py @@ -5,6 +5,32 @@ Transforms boost-root-relative paths to repo-relative paths so that gcovr's second pass (HTML generation) produces clean navigation. +boost-root-relative paths are in the form: +"file": "boost/capy/timeout.hpp", +"file": "boost/capy/when_all.hpp", +"file": "boost/capy/when_any.hpp", +"file": "boost/capy/write.hpp", +"file": "libs/capy/src/buffers/circular_dynamic_buffer.cpp", +"file": "libs/capy/src/cond.cpp", +"file": "libs/capy/src/detail/except.cpp", +"file": "libs/capy/src/error.cpp", + +The files are either in the libs/ directory, with is expected in boost-root +or they have been copied into a global, top-level boost/ directory. + +After running fix_paths.py the same file appear as: + +"file": "include/boost/capy/timeout.hpp", +"file": "include/boost/capy/when_all.hpp", +"file": "include/boost/capy/when_any.hpp", +"file": "include/boost/capy/write.hpp", +"file": "src/buffers/circular_dynamic_buffer.cpp", +"file": "src/cond.cpp", +"file": "src/detail/except.cpp", +"file": "src/error.cpp", + +Which is their regular location within a lib folder. + Usage: python3 fix_paths.py --repo """ diff --git a/scripts/gcovr_build_tree.py b/scripts/gcovr_build_tree.py index 4aabf8f..a04869b 100755 --- a/scripts/gcovr_build_tree.py +++ b/scripts/gcovr_build_tree.py @@ -107,17 +107,108 @@ def get_coverage_class(coverage): def clean_name(raw_name): - """Extract the leaf name from a possibly relative path.""" + """Strip leading relative-path prefixes (./ and ../) but preserve the rest of the path. + + Multi-segment names like 'boost/url/url_view.hpp' are kept intact here + so that normalize_tree() can later split them into a proper nested + directory structure. + """ if not raw_name: return raw_name cleaned = raw_name while cleaned.startswith('../') or cleaned.startswith('./'): cleaned = cleaned[3:] if cleaned.startswith('../') else cleaned[2:] - if '/' in cleaned: - cleaned = cleaned.rsplit('/', 1)[-1] return cleaned or raw_name +def normalize_tree(nodes): + """Expand multi-segment node names into a proper nested directory structure. + + A node whose name is e.g. 'boost/url/url_view.hpp' becomes a synthetic + 'boost' directory containing a 'url' directory containing 'url_view.hpp'. + Multiple entries sharing a prefix get merged under one parent directory. + + This is required because gcovr collapses single-child intermediate + directories in its HTML, so what reaches us as a single 'file' entry + can really represent multiple levels of nesting. + + Doing this here (Python) rather than in the browser keeps the JSON + contract simple: every node has a single-segment name. + """ + if not nodes: + return nodes + + groups = {} + order = [] + + for node in nodes: + name = node.get('name', '') + slash_idx = name.find('/') + + if slash_idx == -1: + if name in groups: + # Merge with existing entry of the same name + existing = groups[name] + if node.get('link'): + existing['link'] = node['link'] + if node.get('coverage'): + existing['coverage'] = node['coverage'] + if node.get('coverageClass'): + existing['coverageClass'] = node['coverageClass'] + if node.get('children'): + existing['children'] = (existing.get('children') or []) + node['children'] + else: + groups[name] = dict(node) + order.append(name) + else: + prefix = name[:slash_idx] + rest = name[slash_idx + 1:] + + if prefix not in groups: + groups[prefix] = { + 'name': prefix, + 'coverage': '', + 'coverageClass': 'coverage-unknown', + 'linesTotal': '', + 'linesExec': '', + 'linesCoverage': '', + 'linesClass': '', + 'functionsCoverage': '', + 'functionsClass': '', + 'branchesCoverage': '', + 'branchesClass': '', + 'isDirectory': True, + 'link': None, + 'children': [], + } + order.append(prefix) + if not groups[prefix].get('children'): + groups[prefix]['children'] = [] + + child_node = dict(node) + child_node['name'] = rest + groups[prefix]['children'].append(child_node) + + result = [] + for name in order: + node = groups[name] + if node.get('children'): + node['children'] = normalize_tree(node['children']) + result.append(node) + + return result + + +def sort_tree(nodes): + """Recursively sort each level: directories first, then files, alphabetical within.""" + if not nodes: + return + nodes.sort(key=lambda x: (not x.get('isDirectory', False), x.get('name', '').lower())) + for node in nodes: + if node.get('children'): + sort_tree(node['children']) + + def build_tree(output_dir): """Build complete tree structure by following links recursively.""" output_path = Path(output_dir) @@ -171,8 +262,10 @@ def build_node_from_file(html_filename, visited=None): nodes.append(node) - # Sort: directories first, then files, alphabetically - nodes.sort(key=lambda x: (not x['isDirectory'], x['name'].lower())) + # Expand any multi-segment names into nested directories, then + # sort every level (directories first, then files, alphabetically). + nodes = normalize_tree(nodes) + sort_tree(nodes) return nodes # Start from index.html @@ -181,35 +274,54 @@ def build_node_from_file(html_filename, visited=None): def inject_tree_data(output_dir, tree): - """Inject tree data as JavaScript variable into all HTML files.""" + """Inject tree data into the generated JS file's placeholder line. + + The JS file contains a final line of the form + window.GCOVR_TREE_DATA = ...; + (either an empty default from the Jinja template, or a previously + injected value). We replace that single assignment with the real tree. + Doing this in one shared .js file rather than inline in every .html + page avoids duplicating the tree JSON across hundreds of HTML files. + + Note: gcovr renames the template file `gcovr.js` to `index.js` in the + output directory, so that's what we target here. + """ output_path = Path(output_dir) - tree_script = f'' + js_file = output_path / 'index.js' + if not js_file.exists(): + print(f"Warning: index.js not found at {js_file}", file=sys.stderr) + return 0 - count = 0 - for html_file in output_path.glob('*.html'): - try: - with open(html_file, 'r', encoding='utf-8') as f: - content = f.read() + new_assignment = f'window.GCOVR_TREE_DATA = {json.dumps(tree)};' - original = content - - if 'window.GCOVR_TREE_DATA' in content: - # Replace existing tree data if present - content = re.sub( - r'', - tree_script, content, flags=re.DOTALL) - elif '' in content: - # First-time injection - content = content.replace('', f'{tree_script}\n') - - if content != original: - with open(html_file, 'w', encoding='utf-8') as f: - f.write(content) - count += 1 - except Exception as e: - print(f"Warning: Could not inject into {html_file}: {e}", file=sys.stderr) + try: + with open(js_file, 'r', encoding='utf-8') as f: + content = f.read() - return count + # Non-greedy match up to the first `;` — safe because file paths + # never contain a literal `;`. DOTALL handles pretty-printed JSON + # spanning multiple lines from `tojson(2)` in the template. + # Pass a lambda for the replacement so that any backslashes in the + # JSON aren't interpreted as regex backreferences. + new_content, count = re.subn( + r'window\.GCOVR_TREE_DATA\s*=\s*.*?;', + lambda _m: new_assignment, + content, + count=1, + flags=re.DOTALL, + ) + + if count == 0: + # No placeholder found — append the assignment so the data is + # still defined globally. + new_content = content.rstrip() + '\n' + new_assignment + '\n' + + with open(js_file, 'w', encoding='utf-8') as f: + f.write(new_content) + return 1 + except Exception as e: + print(f"Warning: Could not inject into {js_file}: {e}", file=sys.stderr) + return 0 def main(): @@ -232,9 +344,12 @@ def main(): print(f"Generated {tree_file} with {len(tree)} root entries") - # Inject tree data into HTML files for local file:// access + # Inject tree data into the generated index.js placeholder injected = inject_tree_data(output_dir, tree) - print(f"Injected tree data into {injected} HTML files") + if injected: + print(f"Injected tree data into {output_dir}/index.js") + else: + print("Warning: tree data was not injected into index.js", file=sys.stderr) if __name__ == '__main__':