diff --git a/CodeMetrics/Coupling.ps1.in b/CodeMetrics/Coupling.ps1.in new file mode 100644 index 0000000..b58e7ef --- /dev/null +++ b/CodeMetrics/Coupling.ps1.in @@ -0,0 +1,228 @@ +$ErrorActionPreference = "Stop" +$inputDirs = @PSC_COUPLING_INPUT_DIRS@ +$excludeDirs = @PSC_COUPLING_EXCLUDE_DIRS@ +$outputDir = "@PSC_COUPLING_OUTPUT_DIR@" +$topN = [int]"@PSC_COUPLING_TOP_N@" +$dotExecutable = "@PSC_COUPLING_DOT@" +function Normalize-Path([string]$Path) { + return [IO.Path]::GetFullPath($Path).Replace("\", "/").TrimEnd("/") +} +function Test-InRoots([string]$Path, [string[]]$Roots) { + $normalized = Normalize-Path $Path + foreach ($root in $Roots) { + if ($normalized.StartsWith($root + "/", [StringComparison]::OrdinalIgnoreCase) -or $normalized.Equals($root, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false +} +function Add-Lookup([Collections.Generic.Dictionary[string, Collections.Generic.List[string]]]$Lookup, [string]$Key, [string]$Value) { + $normalizedKey = $Key.Replace("\", "/").TrimStart("/") + if (!$Lookup.ContainsKey($normalizedKey)) { + $Lookup[$normalizedKey] = [Collections.Generic.List[string]]::new() + } + if (!$Lookup[$normalizedKey].Contains($Value)) { + $Lookup[$normalizedKey].Add($Value) + } +} +$roots = @($inputDirs | ForEach-Object { Normalize-Path $_ }) +$excludes = @($excludeDirs | ForEach-Object { Normalize-Path $_ }) +$extensions = @(".h", ".hh", ".hpp", ".hxx", ".c", ".cc", ".cpp", ".cxx") +$files = [Collections.Generic.List[string]]::new() +foreach ($root in $roots) { + Get-ChildItem -LiteralPath $root -Recurse -File | ForEach-Object { + $path = Normalize-Path $_.FullName + if ($_.Extension.ToLowerInvariant() -in $extensions -and !(Test-InRoots $path $excludes)) { + $files.Add($path) + } + } +} +$files = @($files | Sort-Object -Unique) +$fileSet = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +$lookup = [Collections.Generic.Dictionary[string, Collections.Generic.List[string]]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($file in $files) { + $fileSet.Add($file) | Out-Null + foreach ($root in $roots) { + if (Test-InRoots $file @($root)) { + Add-Lookup $lookup $file.Substring($root.Length).TrimStart("/") $file + } + } +} +function Resolve-Include([string]$Source, [string]$Include) { + $relativeCandidate = Normalize-Path (Join-Path (Split-Path $Source -Parent) $Include) + if ($fileSet.Contains($relativeCandidate)) { + return $relativeCandidate + } + $key = $Include.Replace("\", "/").TrimStart("/") + if ($lookup.ContainsKey($key) -and $lookup[$key].Count -eq 1) { + return $lookup[$key][0] + } + foreach ($root in $roots) { + $rootCandidate = Normalize-Path (Join-Path $root $Include) + if ($fileSet.Contains($rootCandidate)) { + return $rootCandidate + } + } + $suffixMatches = @($files | Where-Object { $_.EndsWith("/" + $key, [StringComparison]::OrdinalIgnoreCase) }) + if ($suffixMatches.Count -eq 1) { + return $suffixMatches[0] + } + return $null +} +$edges = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +$outgoing = [Collections.Generic.Dictionary[string, Collections.Generic.HashSet[string]]]::new([StringComparer]::OrdinalIgnoreCase) +$incoming = [Collections.Generic.Dictionary[string, Collections.Generic.HashSet[string]]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($file in $files) { + $outgoing[$file] = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $incoming[$file] = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +} +foreach ($source in $files) { + foreach ($line in Get-Content -LiteralPath $source) { + if ($line -notmatch '^\s*#\s*include\s*["<](?[^">]+)[">]') { + continue + } + $target = Resolve-Include $source $Matches.include + if (!$target -or $target -eq $source) { + continue + } + $edge = $source + "|" + $target + if ($edges.Add($edge)) { + $outgoing[$source].Add($target) | Out-Null + $incoming[$target].Add($source) | Out-Null + } + } +} +function Relative-Display([string]$Path) { + foreach ($root in $roots) { + if (Test-InRoots $Path @($root)) { + return (Split-Path $root -Leaf) + "/" + $Path.Substring($root.Length).TrimStart("/") + } + } + return $Path +} +function Module-Name([string]$Path) { + foreach ($root in $roots) { + if (Test-InRoots $Path @($root)) { + $relative = $Path.Substring($root.Length).TrimStart("/") + $parts = $relative.Split("/") + $rootName = Split-Path $root -Leaf + if ($parts.Count -gt 1) { + return $rootName + "/" + $parts[0] + } + return $rootName + } + } + return "" +} +$fileStats = @($files | ForEach-Object { + $ca = $incoming[$_].Count + $ce = $outgoing[$_].Count + [pscustomobject]@{ + File = Relative-Display $_ + Afferent = $ca + Efferent = $ce + Coupling = $ca + $ce + Instability = if ($ca + $ce) { [Math]::Round($ce / ($ca + $ce), 4) } else { 0.0 } + } +}) +$moduleEdges = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +$modules = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($file in $files) { + $modules.Add((Module-Name $file)) | Out-Null +} +foreach ($edge in $edges) { + $parts = $edge.Split("|", 2) + $sourceModule = Module-Name $parts[0] + $targetModule = Module-Name $parts[1] + if ($sourceModule -ne $targetModule) { + $moduleEdges.Add($sourceModule + "|" + $targetModule) | Out-Null + } +} +$moduleStats = @($modules | ForEach-Object { + $module = $_ + $ca = @($moduleEdges | Where-Object { $_.Split("|", 2)[1] -eq $module }).Count + $ce = @($moduleEdges | Where-Object { $_.Split("|", 2)[0] -eq $module }).Count + [pscustomobject]@{ + Module = $module + Afferent = $ca + Efferent = $ce + Coupling = $ca + $ce + Instability = if ($ca + $ce) { [Math]::Round($ce / ($ca + $ce), 4) } else { 0.0 } + } +}) +$script:index = 0 +$script:indices = @{} +$script:lowLinks = @{} +$script:onStack = @{} +$script:stack = [Collections.Generic.Stack[string]]::new() +$script:components = [Collections.Generic.List[object]]::new() +function Visit-Node([string]$Node) { + $script:indices[$Node] = $script:index + $script:lowLinks[$Node] = $script:index + $script:index++ + $script:stack.Push($Node) + $script:onStack[$Node] = $true + foreach ($next in $outgoing[$Node]) { + if (!$script:indices.ContainsKey($next)) { + Visit-Node $next + $script:lowLinks[$Node] = [Math]::Min($script:lowLinks[$Node], $script:lowLinks[$next]) + } + elseif ($script:onStack[$next]) { + $script:lowLinks[$Node] = [Math]::Min($script:lowLinks[$Node], $script:indices[$next]) + } + } + if ($script:lowLinks[$Node] -ne $script:indices[$Node]) { + return + } + $component = [Collections.Generic.List[string]]::new() + do { + $member = $script:stack.Pop() + $script:onStack[$member] = $false + $component.Add((Relative-Display $member)) + } while ($member -ne $Node) + if ($component.Count -gt 1) { + $script:components.Add(@($component | Sort-Object)) + } +} +foreach ($file in $files) { + if (!$script:indices.ContainsKey($file)) { + Visit-Node $file + } +} +$fileTop = @($fileStats | Sort-Object @{Expression = "Coupling"; Descending = $true}, @{Expression = "Efferent"; Descending = $true} | Select-Object -First $topN) +$moduleTop = @($moduleStats | Sort-Object @{Expression = "Coupling"; Descending = $true}, @{Expression = "Efferent"; Descending = $true} | Select-Object -First $topN) +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null +$fileTop | Export-Csv -LiteralPath (Join-Path $outputDir "file_coupling.csv") -NoTypeInformation -Encoding UTF8 +$moduleTop | Export-Csv -LiteralPath (Join-Path $outputDir "module_coupling.csv") -NoTypeInformation -Encoding UTF8 +$edgeRecords = @($edges | ForEach-Object { + $parts = $_.Split("|", 2) + [pscustomobject]@{ Source = Relative-Display $parts[0]; Target = Relative-Display $parts[1] } +}) +$edgeRecords | Export-Csv -LiteralPath (Join-Path $outputDir "include_edges.csv") -NoTypeInformation -Encoding UTF8 +$dotPath = Join-Path $outputDir "module_coupling.dot" +$dotLines = [Collections.Generic.List[string]]::new() +$dotLines.Add("digraph coupling {") +$dotLines.Add(' graph [rankdir=LR, bgcolor="transparent"];') +$dotLines.Add(' node [shape=box, style="rounded,filled", fillcolor="#e8eef7", color="#6682a3", fontname="Segoe UI"];') +foreach ($edge in $moduleEdges) { + $parts = $edge.Split("|", 2) + $left = $parts[0].Replace('"', '\"') + $right = $parts[1].Replace('"', '\"') + $dotLines.Add(" `"$left`" -> `"$right`";") +} +$dotLines.Add("}") +[IO.File]::WriteAllLines($dotPath, $dotLines, [Text.UTF8Encoding]::new($false)) +if (Test-Path -LiteralPath $dotExecutable) { + & $dotExecutable "-Tsvg" $dotPath "-o" (Join-Path $outputDir "module_coupling.svg") +} +$summary = [ordered]@{ + files = $files.Count + include_edges = $edges.Count + modules = $modules.Count + module_edges = $moduleEdges.Count + cycles = @($script:components) + file_top = $fileTop + module_top = $moduleTop +} +[IO.File]::WriteAllText((Join-Path $outputDir "summary.json"), ($summary | ConvertTo-Json -Depth 10), [Text.UTF8Encoding]::new($false)) +Write-Output "Coupling report: $outputDir" diff --git a/CodeMetrics/Report.ps1.in b/CodeMetrics/Report.ps1.in new file mode 100644 index 0000000..a6a1aad --- /dev/null +++ b/CodeMetrics/Report.ps1.in @@ -0,0 +1,109 @@ +$ErrorActionPreference = "Stop" +$outputDir = "@PSC_REPORT_OUTPUT_DIR@" +$projectName = "@PSC_REPORT_PROJECT_NAME@" +$paths = [ordered]@{ + llvm = "@PSC_REPORT_LLVM_DIR@" + cppcheck = "@PSC_REPORT_CPPCHECK_DIR@" + lizard = "@PSC_REPORT_LIZARD_DIR@" + code_count = "@PSC_REPORT_CODE_COUNT_DIR@" + coupling = "@PSC_REPORT_COUPLING_DIR@" +} +function Read-Summary([string]$Directory) { + $path = Join-Path $Directory "summary.json" + if (!(Test-Path -LiteralPath $path)) { + throw "Metrics summary was not found at $path" + } + return Get-Content -LiteralPath $path -Raw | ConvertFrom-Json +} +function H([object]$Value) { + return [Net.WebUtility]::HtmlEncode([string]$Value) +} +function Metric-Table([string]$Title, [object[]]$Rows, [string[]]$Columns) { + $builder = [Text.StringBuilder]::new() + [void]$builder.Append("

$(H $Title)

") + foreach ($column in $Columns) { + [void]$builder.Append("") + } + [void]$builder.Append("") + foreach ($row in @($Rows)) { + [void]$builder.Append("") + foreach ($column in $Columns) { + [void]$builder.Append("") + } + [void]$builder.Append("") + } + [void]$builder.Append("
$(H $column)
$(H $row.$column)
") + return $builder.ToString() +} +$data = [ordered]@{} +foreach ($entry in $paths.GetEnumerator()) { + $data[$entry.Key] = Read-Summary $entry.Value +} +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null +$generatedAt = [DateTimeOffset]::Now.ToString("yyyy-MM-dd HH:mm:ss zzz") +$html = [Text.StringBuilder]::new() +[void]$html.Append(@" + + + + + +$(H $projectName) Engineering Metrics + + +
+

$(H $projectName) 软件工程指标

静态分析、复杂度、代码规模与耦合度统一报告
$(H $generatedAt)
+
+
$($data.code_count.totals.code)代码行
+
$($data.lizard.functions)函数数
+
$($data.lizard.average_ccn)平均圈复杂度
+
$($data.llvm.findings)认知复杂度记录
+
$($data.coupling.include_edges)内部 include 边
+
$($data.cppcheck.findings)Cppcheck 发现
+
+"@) +[void]$html.Append((Metric-Table "语言与代码规模" @($data.code_count.languages) @("Language", "Files", "Code", "Comments", "Blanks", "Total"))) +[void]$html.Append((Metric-Table "圈复杂度 Top" @($data.lizard.cyclomatic_top) @("CCN", "NLOC", "Function", "File", "StartLine"))) +[void]$html.Append((Metric-Table "认知复杂度 Top" @($data.llvm.top) @("Score", "Function", "File", "Line"))) +[void]$html.Append((Metric-Table "文件耦合度 Top" @($data.coupling.file_top) @("Coupling", "Afferent", "Efferent", "Instability", "File"))) +[void]$html.Append((Metric-Table "模块耦合度 Top" @($data.coupling.module_top) @("Coupling", "Afferent", "Efferent", "Instability", "Module"))) +[void]$html.Append((Metric-Table "Cppcheck 发现" @($data.cppcheck.top) @("Severity", "Id", "Message", "File", "Line"))) +[void]$html.Append(@" +

依赖图

打开模块耦合 SVG

循环依赖组件数:$(@($data.coupling.cycles).Count)

+
原始 JSON、CSV、XML、DOT 和 SVG 均保留在各子目录,可供 CI 或后续基准比较。
+
+"@) +[IO.File]::WriteAllText((Join-Path $outputDir "index.html"), $html.ToString(), [Text.UTF8Encoding]::new($false)) +$combined = [ordered]@{ + project = $projectName + generated_at = $generatedAt + code_count = $data.code_count + lizard = $data.lizard + llvm = $data.llvm + cppcheck = $data.cppcheck + coupling = $data.coupling +} +[IO.File]::WriteAllText((Join-Path $outputDir "metrics.json"), ($combined | ConvertTo-Json -Depth 12), [Text.UTF8Encoding]::new($false)) +$markdown = @( + "# $projectName 软件工程指标" + "" + "- 代码行:$($data.code_count.totals.code)" + "- 函数数:$($data.lizard.functions)" + "- 平均圈复杂度:$($data.lizard.average_ccn)" + "- Cppcheck 发现:$($data.cppcheck.findings)" + "- 内部 include 边:$($data.coupling.include_edges)" + "" + "详细表格请打开 `index.html`。" +) +[IO.File]::WriteAllLines((Join-Path $outputDir "README.md"), $markdown, [Text.UTF8Encoding]::new($false)) +Write-Output "Engineering metrics report: $outputDir" diff --git a/CodeMetrics/export.cmake b/CodeMetrics/export.cmake new file mode 100644 index 0000000..44e4407 --- /dev/null +++ b/CodeMetrics/export.cmake @@ -0,0 +1 @@ +include(${CMAKE_CURRENT_LIST_DIR}/main.cmake) diff --git a/CodeMetrics/main.cmake b/CodeMetrics/main.cmake new file mode 100644 index 0000000..23dd129 --- /dev/null +++ b/CodeMetrics/main.cmake @@ -0,0 +1,70 @@ +function(psc_metrics_ps_array out_value) + set(values) + foreach(item IN LISTS ARGN) + string(REPLACE "'" "''" escaped "${item}") + list(APPEND values "'${escaped}'") + endforeach() + string(REPLACE ";" "," value "${values}") + set(${out_value} "@(${value})" PARENT_SCOPE) +endfunction() +function(psc_add_coupling_target target_name) + set(options) + set(oneValueArgs OUTPUT_DIR TOP_N DOT_EXECUTABLE) + set(multiValueArgs INPUT_DIRS EXCLUDE_DIRS) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + if (NOT ARG_OUTPUT_DIR) + set(ARG_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/metrics/${target_name}/coupling") + endif () + if (NOT ARG_TOP_N) + set(ARG_TOP_N 50) + endif () + if (NOT ARG_DOT_EXECUTABLE) + set(ARG_DOT_EXECUTABLE "C:/Program Files/Graphviz/bin/dot.exe") + endif () + if (NOT ARG_INPUT_DIRS) + set(ARG_INPUT_DIRS "${CMAKE_SOURCE_DIR}") + endif () + psc_metrics_ps_array(PSC_COUPLING_INPUT_DIRS ${ARG_INPUT_DIRS}) + psc_metrics_ps_array(PSC_COUPLING_EXCLUDE_DIRS ${ARG_EXCLUDE_DIRS}) + set(PSC_COUPLING_OUTPUT_DIR "${ARG_OUTPUT_DIR}") + set(PSC_COUPLING_TOP_N "${ARG_TOP_N}") + set(PSC_COUPLING_DOT "${ARG_DOT_EXECUTABLE}") + set(script_dir "${CMAKE_CURRENT_BINARY_DIR}/psc_metrics_scripts/${target_name}") + file(MAKE_DIRECTORY "${script_dir}") + set(script "${script_dir}/${target_name}_coupling.ps1") + configure_file("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/Coupling.ps1.in" "${script}" @ONLY) + add_custom_target(${target_name} + COMMAND pwsh -NoProfile -ExecutionPolicy Bypass -File "${script}" + VERBATIM + ) + set(${target_name}_OUTPUT_DIR "${ARG_OUTPUT_DIR}" PARENT_SCOPE) +endfunction() +function(psc_add_metrics_report_target target_name) + set(options) + set(oneValueArgs OUTPUT_DIR LLVM_DIR CPPCHECK_DIR LIZARD_DIR CODE_COUNT_DIR COUPLING_DIR PROJECT_NAME) + set(multiValueArgs DEPENDS) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + if (NOT ARG_OUTPUT_DIR) + set(ARG_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/metrics/${target_name}") + endif () + if (NOT ARG_PROJECT_NAME) + set(ARG_PROJECT_NAME "${PROJECT_NAME}") + endif () + set(PSC_REPORT_OUTPUT_DIR "${ARG_OUTPUT_DIR}") + set(PSC_REPORT_LLVM_DIR "${ARG_LLVM_DIR}") + set(PSC_REPORT_CPPCHECK_DIR "${ARG_CPPCHECK_DIR}") + set(PSC_REPORT_LIZARD_DIR "${ARG_LIZARD_DIR}") + set(PSC_REPORT_CODE_COUNT_DIR "${ARG_CODE_COUNT_DIR}") + set(PSC_REPORT_COUPLING_DIR "${ARG_COUPLING_DIR}") + set(PSC_REPORT_PROJECT_NAME "${ARG_PROJECT_NAME}") + set(script_dir "${CMAKE_CURRENT_BINARY_DIR}/psc_metrics_scripts/${target_name}") + file(MAKE_DIRECTORY "${script_dir}") + set(script "${script_dir}/${target_name}_report.ps1") + configure_file("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/Report.ps1.in" "${script}" @ONLY) + add_custom_target(${target_name} + COMMAND pwsh -NoProfile -ExecutionPolicy Bypass -File "${script}" + DEPENDS ${ARG_DEPENDS} + VERBATIM + ) + set(${target_name}_OUTPUT_DIR "${ARG_OUTPUT_DIR}" PARENT_SCOPE) +endfunction() diff --git a/Cppcheck/Cppcheck.ps1.in b/Cppcheck/Cppcheck.ps1.in new file mode 100644 index 0000000..1c418a9 --- /dev/null +++ b/Cppcheck/Cppcheck.ps1.in @@ -0,0 +1,123 @@ +$ErrorActionPreference = "Stop" +$inputDirs = @PSC_CPPCHECK_INPUT_DIRS@ +$excludeDirs = @PSC_CPPCHECK_EXCLUDE_DIRS@ +$enabledChecks = @PSC_CPPCHECK_ENABLE@ +$suppressions = @PSC_CPPCHECK_SUPPRESSIONS@ +$outputDir = "@PSC_CPPCHECK_OUTPUT_DIR@" +$compileCommands = "@PSC_CPPCHECK_COMPILE_COMMANDS@" +$configuredCppcheck = "@PSC_CPPCHECK_EXECUTABLE@" +$checkLevel = "@PSC_CPPCHECK_CHECK_LEVEL@" +$jobs = [int]"@PSC_CPPCHECK_JOBS@" +function Resolve-Tool([string]$Configured, [string]$Name, [string[]]$Fallbacks) { + if ($Configured -and (Test-Path -LiteralPath $Configured)) { + return (Resolve-Path -LiteralPath $Configured).Path + } + $command = Get-Command $Name -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + return $command.Source + } + foreach ($fallback in $Fallbacks) { + if (Test-Path -LiteralPath $fallback) { + return (Resolve-Path -LiteralPath $fallback).Path + } + } + throw "$Name executable was not found" +} +function Normalize-Path([string]$Path) { + return [IO.Path]::GetFullPath($Path).Replace("\", "/").TrimEnd("/") +} +function Test-InRoots([string]$Path, [string[]]$Roots) { + $normalized = Normalize-Path $Path + foreach ($root in $Roots) { + if ($normalized.StartsWith($root + "/", [StringComparison]::OrdinalIgnoreCase) -or $normalized.Equals($root, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false +} +$cppcheck = Resolve-Tool $configuredCppcheck "cppcheck" @("C:\Program Files\Cppcheck\cppcheck.exe") +if (!(Test-Path -LiteralPath $compileCommands)) { + throw "compile_commands.json was not found at $compileCommands" +} +$roots = @($inputDirs | ForEach-Object { Normalize-Path $_ }) +$excludes = @($excludeDirs | ForEach-Object { Normalize-Path $_ }) +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null +$database = Get-Content -LiteralPath $compileCommands -Raw | ConvertFrom-Json +$selected = @($database | Where-Object { + $file = Normalize-Path $_.file + (Test-InRoots $file $roots) -and !(Test-InRoots $file $excludes) -and [IO.Path]::GetExtension($file) -in @(".c", ".cc", ".cpp", ".cxx") +}) +if ($selected.Count -eq 0) { + throw "No translation units matched the configured input directories" +} +$filteredDatabase = Join-Path $outputDir "compile_commands.json" +[IO.File]::WriteAllText($filteredDatabase, ($selected | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +$xmlPath = Join-Path $outputDir "cppcheck.xml" +$cacheDir = Join-Path $outputDir "cache" +New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null +$arguments = @( + "--project=$filteredDatabase", + "--enable=$($enabledChecks -join ',')", + "--check-level=$checkLevel", + "--std=c++20", + "--inconclusive", + "--inline-suppr", + "--output-format=xmlv3", + "--output-file=$xmlPath", + "--cppcheck-build-dir=$cacheDir" + "-j" + $jobs +) +foreach ($suppression in $suppressions) { + $arguments += "--suppress=$suppression" +} +& $cppcheck @arguments +$toolExitCode = $LASTEXITCODE +if (!(Test-Path -LiteralPath $xmlPath)) { + throw "Cppcheck did not produce $xmlPath" +} +[xml]$xml = Get-Content -LiteralPath $xmlPath -Raw +$records = [Collections.Generic.List[object]]::new() +foreach ($errorNode in $xml.results.errors.error) { + $locations = @($errorNode.location) + if ($locations.Count -eq 0) { + $records.Add([pscustomobject]@{ + Severity = [string]$errorNode.severity + Id = [string]$errorNode.id + Message = [string]$errorNode.msg + File = "" + Line = 0 + Column = 0 + }) + continue + } + foreach ($location in $locations) { + $locationFile = [string]$location.file + if ($locationFile -and (!(Test-InRoots $locationFile $roots) -or (Test-InRoots $locationFile $excludes))) { + continue + } + $records.Add([pscustomobject]@{ + Severity = [string]$errorNode.severity + Id = [string]$errorNode.id + Message = [string]$errorNode.msg + File = $locationFile + Line = [int]$location.line + Column = [int]$location.column + }) + } +} +$records | Export-Csv -LiteralPath (Join-Path $outputDir "cppcheck.csv") -NoTypeInformation -Encoding UTF8 +$severity = @($records | Group-Object Severity | Sort-Object Count -Descending | ForEach-Object { + [pscustomobject]@{ severity = $_.Name; count = $_.Count } +}) +$summary = [ordered]@{ + tool = "cppcheck" + version = (& $cppcheck --version | Select-Object -First 1).Trim() + translation_units = $selected.Count + tool_exit_code = $toolExitCode + findings = $records.Count + severity = $severity + top = @($records | Select-Object -First 100) +} +[IO.File]::WriteAllText((Join-Path $outputDir "summary.json"), ($summary | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +Write-Output "Cppcheck report: $outputDir" diff --git a/Cppcheck/Install.ps1 b/Cppcheck/Install.ps1 new file mode 100644 index 0000000..2466904 --- /dev/null +++ b/Cppcheck/Install.ps1 @@ -0,0 +1,21 @@ +param( + [string]$Version = "", + [switch]$Force +) +$ErrorActionPreference = "Stop" +$arguments = @("install", "--id", "Cppcheck.Cppcheck", "--exact", "--silent", "--accept-package-agreements", "--accept-source-agreements", "--disable-interactivity") +if ($Version) { + $arguments += @("--version", $Version) +} +if ($Force) { + $arguments += "--force" +} +& winget @arguments +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} +$cppcheck = "C:\Program Files\Cppcheck\cppcheck.exe" +if (!(Test-Path -LiteralPath $cppcheck)) { + throw "Cppcheck was not installed at $cppcheck" +} +& $cppcheck --version diff --git a/Cppcheck/export.cmake b/Cppcheck/export.cmake new file mode 100644 index 0000000..44e4407 --- /dev/null +++ b/Cppcheck/export.cmake @@ -0,0 +1 @@ +include(${CMAKE_CURRENT_LIST_DIR}/main.cmake) diff --git a/Cppcheck/main.cmake b/Cppcheck/main.cmake new file mode 100644 index 0000000..bfee5cc --- /dev/null +++ b/Cppcheck/main.cmake @@ -0,0 +1,57 @@ +function(psc_cppcheck_ps_array out_value) + set(values) + foreach(item IN LISTS ARGN) + string(REPLACE "'" "''" escaped "${item}") + list(APPEND values "'${escaped}'") + endforeach() + string(REPLACE ";" "," value "${values}") + set(${out_value} "@(${value})" PARENT_SCOPE) +endfunction() +function(psc_add_cppcheck_target target_name) + set(options) + set(oneValueArgs OUTPUT_DIR COMPILE_COMMANDS CPPCHECK_EXECUTABLE CHECK_LEVEL JOBS) + set(multiValueArgs INPUT_DIRS EXCLUDE_DIRS ENABLE SUPPRESSIONS) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + if (NOT ARG_OUTPUT_DIR) + set(ARG_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/metrics/${target_name}/cppcheck") + endif () + if (NOT ARG_COMPILE_COMMANDS) + set(ARG_COMPILE_COMMANDS "${CMAKE_BINARY_DIR}/compile_commands.json") + endif () + if (NOT ARG_CPPCHECK_EXECUTABLE) + set(ARG_CPPCHECK_EXECUTABLE "C:/Program Files/Cppcheck/cppcheck.exe") + endif () + if (NOT ARG_CHECK_LEVEL) + set(ARG_CHECK_LEVEL "normal") + endif () + if (NOT ARG_JOBS) + set(ARG_JOBS 8) + endif () + if (NOT ARG_INPUT_DIRS) + set(ARG_INPUT_DIRS "${CMAKE_SOURCE_DIR}") + endif () + if (NOT ARG_ENABLE) + set(ARG_ENABLE warning style performance portability) + endif () + if (NOT ARG_SUPPRESSIONS) + set(ARG_SUPPRESSIONS missingIncludeSystem unmatchedSuppression) + endif () + psc_cppcheck_ps_array(PSC_CPPCHECK_INPUT_DIRS ${ARG_INPUT_DIRS}) + psc_cppcheck_ps_array(PSC_CPPCHECK_EXCLUDE_DIRS ${ARG_EXCLUDE_DIRS}) + psc_cppcheck_ps_array(PSC_CPPCHECK_ENABLE ${ARG_ENABLE}) + psc_cppcheck_ps_array(PSC_CPPCHECK_SUPPRESSIONS ${ARG_SUPPRESSIONS}) + set(PSC_CPPCHECK_OUTPUT_DIR "${ARG_OUTPUT_DIR}") + set(PSC_CPPCHECK_COMPILE_COMMANDS "${ARG_COMPILE_COMMANDS}") + set(PSC_CPPCHECK_EXECUTABLE "${ARG_CPPCHECK_EXECUTABLE}") + set(PSC_CPPCHECK_CHECK_LEVEL "${ARG_CHECK_LEVEL}") + set(PSC_CPPCHECK_JOBS "${ARG_JOBS}") + set(script_dir "${CMAKE_CURRENT_BINARY_DIR}/psc_metrics_scripts/${target_name}") + file(MAKE_DIRECTORY "${script_dir}") + set(script "${script_dir}/${target_name}_cppcheck.ps1") + configure_file("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/Cppcheck.ps1.in" "${script}" @ONLY) + add_custom_target(${target_name} + COMMAND pwsh -NoProfile -ExecutionPolicy Bypass -File "${script}" + VERBATIM + ) + set(${target_name}_OUTPUT_DIR "${ARG_OUTPUT_DIR}" PARENT_SCOPE) +endfunction() diff --git a/LLVM/Cognitive_Complexity.ps1.in b/LLVM/Cognitive_Complexity.ps1.in new file mode 100644 index 0000000..59bbcb0 --- /dev/null +++ b/LLVM/Cognitive_Complexity.ps1.in @@ -0,0 +1,119 @@ +$ErrorActionPreference = "Stop" +$inputDirs = @PSC_LLVM_INPUT_DIRS@ +$excludeDirs = @PSC_LLVM_EXCLUDE_DIRS@ +$outputDir = "@PSC_LLVM_OUTPUT_DIR@" +$compileCommands = "@PSC_LLVM_COMPILE_COMMANDS@" +$configuredClangTidy = "@PSC_LLVM_CLANG_TIDY@" +$topN = [int]"@PSC_LLVM_TOP_N@" +$threshold = [int]"@PSC_LLVM_THRESHOLD@" +function Resolve-Tool([string]$Configured, [string]$Name, [string[]]$Fallbacks) { + if ($Configured -and (Test-Path -LiteralPath $Configured)) { + return (Resolve-Path -LiteralPath $Configured).Path + } + $command = Get-Command $Name -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + return $command.Source + } + foreach ($fallback in $Fallbacks) { + if (Test-Path -LiteralPath $fallback) { + return (Resolve-Path -LiteralPath $fallback).Path + } + } + throw "$Name executable was not found" +} +function Normalize-Path([string]$Path) { + return [IO.Path]::GetFullPath($Path).Replace("\", "/").TrimEnd("/") +} +function Test-InRoots([string]$Path, [string[]]$Roots) { + $normalized = Normalize-Path $Path + foreach ($root in $Roots) { + if ($normalized.StartsWith($root + "/", [StringComparison]::OrdinalIgnoreCase) -or $normalized.Equals($root, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false +} +$clangTidy = Resolve-Tool $configuredClangTidy "clang-tidy" @("C:\Program Files\LLVM\bin\clang-tidy.exe") +$clangCl = Join-Path (Split-Path $clangTidy -Parent) "clang-cl.exe" +if (!(Test-Path -LiteralPath $clangCl)) { + throw "clang-cl executable was not found next to clang-tidy" +} +if (!(Test-Path -LiteralPath $compileCommands)) { + throw "compile_commands.json was not found at $compileCommands" +} +$roots = @($inputDirs | ForEach-Object { Normalize-Path $_ }) +$excludes = @($excludeDirs | ForEach-Object { Normalize-Path $_ }) +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null +$databaseDir = Join-Path $outputDir "compile_database" +New-Item -ItemType Directory -Force -Path $databaseDir | Out-Null +$database = Get-Content -LiteralPath $compileCommands -Raw | ConvertFrom-Json +$selected = [Collections.Generic.List[object]]::new() +foreach ($entry in $database) { + $file = Normalize-Path $entry.file + if (!(Test-InRoots $file $roots) -or (Test-InRoots $file $excludes)) { + continue + } + if ([IO.Path]::GetExtension($file) -notin @(".c", ".cc", ".cpp", ".cxx")) { + continue + } + $command = [string]$entry.command + $escapedClang = '"' + $clangCl + '"' + $command = [regex]::Replace($command, '^\s*(?:"[^"]+"|[^\s]+?)(?:cl(?:\.exe|\.bat))\s+', $escapedClang + " ", [Text.RegularExpressions.RegexOptions]::IgnoreCase) + $command = [regex]::Replace($command, '(?i)(?:^|\s)[/-](?:Fo|Fd|Fp|FI|Yu|Yc)(?:"[^"]*"|\S+)', " ") + $command = [regex]::Replace($command, '(?i)(?:^|\s)(?:/FS|/nologo|/RTC1|/Ob\d|/Od|-Z7|-MDd?)\b', " ") + $command = [regex]::Replace($command, '(?i)-external:I', "-I") + $command = [regex]::Replace($command, '(?i)\s-external:W\d\b', " ") + $command = [regex]::Replace($command, '\s+', " ").Trim() + $selected.Add([ordered]@{ + directory = $entry.directory + command = $command + file = $entry.file + output = $entry.output + }) +} +if ($selected.Count -eq 0) { + throw "No translation units matched the configured input directories" +} +$sanitizedDatabase = Join-Path $databaseDir "compile_commands.json" +[IO.File]::WriteAllText($sanitizedDatabase, ($selected | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +$rawPath = Join-Path $outputDir "clang_tidy_raw.txt" +$rawWriter = [IO.StreamWriter]::new($rawPath, $false, [Text.UTF8Encoding]::new($false)) +$records = [Collections.Generic.List[object]]::new() +$diagnosticErrors = 0 +$config = "{CheckOptions: {readability-function-cognitive-complexity.Threshold: $threshold, readability-function-cognitive-complexity.DescribeBasicIncrements: false, readability-function-cognitive-complexity.IgnoreMacros: false}}" +foreach ($entry in $selected) { + $lines = & $clangTidy $entry.file "-p=$databaseDir" "-checks=-*,readability-function-cognitive-complexity" "-config=$config" "--quiet" 2>&1 + foreach ($lineObject in $lines) { + $line = [string]$lineObject + $rawWriter.WriteLine($line) + if ($line -match '^(?.+?):(?\d+):(?\d+): warning: (?:(?:function ''(?[^'']+)'')|(?lambda)) has cognitive complexity of (?\d+)') { + $file = Normalize-Path $Matches.file + if ((Test-InRoots $file $roots) -and !(Test-InRoots $file $excludes)) { + $records.Add([pscustomobject]@{ + Score = [int]$Matches.score + Function = if ($Matches.name) { $Matches.name } else { "" } + File = $file + Line = [int]$Matches.line + Column = [int]$Matches.column + }) + } + } + elseif ($line -match '(^| )error:') { + $diagnosticErrors++ + } + } +} +$rawWriter.Dispose() +$top = @($records | Sort-Object @{Expression = "Score"; Descending = $true}, File, Line | Select-Object -First $topN) +$top | Export-Csv -LiteralPath (Join-Path $outputDir "cognitive_complexity.csv") -NoTypeInformation -Encoding UTF8 +$summary = [ordered]@{ + tool = "clang-tidy" + version = (& $clangTidy --version | Select-Object -First 1).Trim() + threshold = $threshold + translation_units = $selected.Count + findings = $records.Count + diagnostic_errors = $diagnosticErrors + top = $top +} +[IO.File]::WriteAllText((Join-Path $outputDir "summary.json"), ($summary | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +Write-Output "LLVM cognitive complexity report: $outputDir" diff --git a/LLVM/Install.ps1 b/LLVM/Install.ps1 new file mode 100644 index 0000000..1ad8253 --- /dev/null +++ b/LLVM/Install.ps1 @@ -0,0 +1,21 @@ +param( + [string]$Version = "", + [switch]$Force +) +$ErrorActionPreference = "Stop" +$arguments = @("install", "--id", "LLVM.LLVM", "--exact", "--silent", "--accept-package-agreements", "--accept-source-agreements", "--disable-interactivity") +if ($Version) { + $arguments += @("--version", $Version) +} +if ($Force) { + $arguments += "--force" +} +& winget @arguments +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} +$clangTidy = "C:\Program Files\LLVM\bin\clang-tidy.exe" +if (!(Test-Path -LiteralPath $clangTidy)) { + throw "clang-tidy was not installed at $clangTidy" +} +& $clangTidy --version diff --git a/LLVM/export.cmake b/LLVM/export.cmake new file mode 100644 index 0000000..44e4407 --- /dev/null +++ b/LLVM/export.cmake @@ -0,0 +1 @@ +include(${CMAKE_CURRENT_LIST_DIR}/main.cmake) diff --git a/LLVM/main.cmake b/LLVM/main.cmake new file mode 100644 index 0000000..6f26821 --- /dev/null +++ b/LLVM/main.cmake @@ -0,0 +1,49 @@ +function(psc_llvm_ps_array out_value) + set(values) + foreach(item IN LISTS ARGN) + string(REPLACE "'" "''" escaped "${item}") + list(APPEND values "'${escaped}'") + endforeach() + string(REPLACE ";" "," value "${values}") + set(${out_value} "@(${value})" PARENT_SCOPE) +endfunction() +function(psc_add_llvm_cognitive_target target_name) + set(options) + set(oneValueArgs OUTPUT_DIR COMPILE_COMMANDS CLANG_TIDY_EXECUTABLE TOP_N THRESHOLD) + set(multiValueArgs INPUT_DIRS EXCLUDE_DIRS) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + if (NOT ARG_OUTPUT_DIR) + set(ARG_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/metrics/${target_name}/llvm") + endif () + if (NOT ARG_COMPILE_COMMANDS) + set(ARG_COMPILE_COMMANDS "${CMAKE_BINARY_DIR}/compile_commands.json") + endif () + if (NOT ARG_CLANG_TIDY_EXECUTABLE) + set(ARG_CLANG_TIDY_EXECUTABLE "C:/Program Files/LLVM/bin/clang-tidy.exe") + endif () + if (NOT ARG_TOP_N) + set(ARG_TOP_N 50) + endif () + if (NOT DEFINED ARG_THRESHOLD) + set(ARG_THRESHOLD 0) + endif () + if (NOT ARG_INPUT_DIRS) + set(ARG_INPUT_DIRS "${CMAKE_SOURCE_DIR}") + endif () + psc_llvm_ps_array(PSC_LLVM_INPUT_DIRS ${ARG_INPUT_DIRS}) + psc_llvm_ps_array(PSC_LLVM_EXCLUDE_DIRS ${ARG_EXCLUDE_DIRS}) + set(PSC_LLVM_OUTPUT_DIR "${ARG_OUTPUT_DIR}") + set(PSC_LLVM_COMPILE_COMMANDS "${ARG_COMPILE_COMMANDS}") + set(PSC_LLVM_CLANG_TIDY "${ARG_CLANG_TIDY_EXECUTABLE}") + set(PSC_LLVM_TOP_N "${ARG_TOP_N}") + set(PSC_LLVM_THRESHOLD "${ARG_THRESHOLD}") + set(script_dir "${CMAKE_CURRENT_BINARY_DIR}/psc_metrics_scripts/${target_name}") + file(MAKE_DIRECTORY "${script_dir}") + set(script "${script_dir}/${target_name}_llvm_cognitive.ps1") + configure_file("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/Cognitive_Complexity.ps1.in" "${script}" @ONLY) + add_custom_target(${target_name} + COMMAND pwsh -NoProfile -ExecutionPolicy Bypass -File "${script}" + VERBATIM + ) + set(${target_name}_OUTPUT_DIR "${ARG_OUTPUT_DIR}" PARENT_SCOPE) +endfunction() diff --git a/cloc_tokei/Code_Count.ps1.in b/cloc_tokei/Code_Count.ps1.in new file mode 100644 index 0000000..13d21e1 --- /dev/null +++ b/cloc_tokei/Code_Count.ps1.in @@ -0,0 +1,89 @@ +$ErrorActionPreference = "Stop" +$inputDirs = @PSC_CODE_COUNT_INPUT_DIRS@ +$excludeDirs = @PSC_CODE_COUNT_EXCLUDE_DIRS@ +$outputDir = "@PSC_CODE_COUNT_OUTPUT_DIR@" +$configuredTokei = "@PSC_CODE_COUNT_TOKEI@" +$configuredCloc = "@PSC_CODE_COUNT_CLOC@" +function Resolve-Tool([string]$Configured, [string]$Name, [string[]]$Fallbacks) { + if ($Configured -and (Test-Path -LiteralPath $Configured)) { + return (Resolve-Path -LiteralPath $Configured).Path + } + $command = Get-Command $Name -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + return $command.Source + } + foreach ($fallback in $Fallbacks) { + if (Test-Path -LiteralPath $fallback) { + return (Resolve-Path -LiteralPath $fallback).Path + } + } + throw "$Name executable was not found" +} +$wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links" +$tokei = Resolve-Tool $configuredTokei "tokei" @((Join-Path $wingetLinks "tokei.exe")) +$cloc = Resolve-Tool $configuredCloc "cloc" @((Join-Path $wingetLinks "cloc.exe")) +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null +$tokeiArguments = [Collections.Generic.List[string]]::new() +$tokeiArguments.Add("--output") +$tokeiArguments.Add("json") +foreach ($exclude in $excludeDirs) { + $tokeiArguments.Add("--exclude") + $tokeiArguments.Add(($exclude.Replace("\", "/") + "/*")) +} +foreach ($inputDir in $inputDirs) { + $tokeiArguments.Add($inputDir) +} +$tokeiPath = Join-Path $outputDir "tokei.json" +& $tokei @tokeiArguments | Set-Content -LiteralPath $tokeiPath -Encoding UTF8 +if ($LASTEXITCODE -ne 0) { + throw "tokei failed with exit code $LASTEXITCODE" +} +$excludePattern = if ($excludeDirs.Count) { + ($excludeDirs | ForEach-Object { [regex]::Escape(([IO.Path]::GetFullPath($_)).Replace("\", "/")) }) -join "|" +} else { + "(?!)" +} +$clocJsonPath = Join-Path $outputDir "cloc.json" +$clocXmlPath = Join-Path $outputDir "cloc.xml" +& $cloc @inputDirs "--json" "--by-file" "--fullpath" "--not-match-d=$excludePattern" "--out=$clocJsonPath" +if ($LASTEXITCODE -ne 0) { + throw "cloc JSON generation failed with exit code $LASTEXITCODE" +} +& $cloc @inputDirs "--xml" "--by-file" "--fullpath" "--not-match-d=$excludePattern" "--out=$clocXmlPath" +if ($LASTEXITCODE -ne 0) { + throw "cloc XML generation failed with exit code $LASTEXITCODE" +} +$tokeiData = Get-Content -LiteralPath $tokeiPath -Raw | ConvertFrom-Json +$languages = [Collections.Generic.List[object]]::new() +foreach ($property in $tokeiData.PSObject.Properties) { + if ($property.Name -eq "Total") { + continue + } + $value = $property.Value + $fileCount = if ($value.reports) { @($value.reports).Count } else { 0 } + $languages.Add([pscustomobject]@{ + Language = $property.Name + Files = $fileCount + Code = [int64]$value.code + Comments = [int64]$value.comments + Blanks = [int64]$value.blanks + Total = [int64]$value.code + [int64]$value.comments + [int64]$value.blanks + }) +} +$languageSummary = @($languages | Sort-Object Code -Descending) +$languageSummary | Export-Csv -LiteralPath (Join-Path $outputDir "languages.csv") -NoTypeInformation -Encoding UTF8 +$summary = [ordered]@{ + tools = [ordered]@{ + tokei = (& $tokei --version | Select-Object -First 1).Trim() + cloc = (& $cloc --version | Select-Object -First 1).Trim() + } + totals = [ordered]@{ + files = [int64](($languageSummary | Measure-Object Files -Sum).Sum) + code = [int64](($languageSummary | Measure-Object Code -Sum).Sum) + comments = [int64](($languageSummary | Measure-Object Comments -Sum).Sum) + blanks = [int64](($languageSummary | Measure-Object Blanks -Sum).Sum) + } + languages = $languageSummary +} +[IO.File]::WriteAllText((Join-Path $outputDir "summary.json"), ($summary | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +Write-Output "Code count report: $outputDir" diff --git a/cloc_tokei/Install.ps1 b/cloc_tokei/Install.ps1 new file mode 100644 index 0000000..93630ef --- /dev/null +++ b/cloc_tokei/Install.ps1 @@ -0,0 +1,17 @@ +param( + [switch]$Force +) +$ErrorActionPreference = "Stop" +foreach ($package in @("XAMPPRocky.Tokei", "AlDanial.Cloc")) { + $arguments = @("install", "--id", $package, "--exact", "--silent", "--accept-package-agreements", "--accept-source-agreements", "--disable-interactivity") + if ($Force) { + $arguments += "--force" + } + & winget @arguments + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} +$linkDir = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links" +& (Join-Path $linkDir "tokei.exe") --version +& (Join-Path $linkDir "cloc.exe") --version diff --git a/cloc_tokei/export.cmake b/cloc_tokei/export.cmake new file mode 100644 index 0000000..44e4407 --- /dev/null +++ b/cloc_tokei/export.cmake @@ -0,0 +1 @@ +include(${CMAKE_CURRENT_LIST_DIR}/main.cmake) diff --git a/cloc_tokei/main.cmake b/cloc_tokei/main.cmake new file mode 100644 index 0000000..a2a095b --- /dev/null +++ b/cloc_tokei/main.cmake @@ -0,0 +1,41 @@ +function(psc_code_count_ps_array out_value) + set(values) + foreach(item IN LISTS ARGN) + string(REPLACE "'" "''" escaped "${item}") + list(APPEND values "'${escaped}'") + endforeach() + string(REPLACE ";" "," value "${values}") + set(${out_value} "@(${value})" PARENT_SCOPE) +endfunction() +function(psc_add_code_count_target target_name) + set(options) + set(oneValueArgs OUTPUT_DIR TOKEI_EXECUTABLE CLOC_EXECUTABLE) + set(multiValueArgs INPUT_DIRS EXCLUDE_DIRS) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + if (NOT ARG_OUTPUT_DIR) + set(ARG_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/metrics/${target_name}/code_count") + endif () + if (NOT ARG_TOKEI_EXECUTABLE) + set(ARG_TOKEI_EXECUTABLE "$ENV{LOCALAPPDATA}/Microsoft/WinGet/Links/tokei.exe") + endif () + if (NOT ARG_CLOC_EXECUTABLE) + set(ARG_CLOC_EXECUTABLE "$ENV{LOCALAPPDATA}/Microsoft/WinGet/Links/cloc.exe") + endif () + if (NOT ARG_INPUT_DIRS) + set(ARG_INPUT_DIRS "${CMAKE_SOURCE_DIR}") + endif () + psc_code_count_ps_array(PSC_CODE_COUNT_INPUT_DIRS ${ARG_INPUT_DIRS}) + psc_code_count_ps_array(PSC_CODE_COUNT_EXCLUDE_DIRS ${ARG_EXCLUDE_DIRS}) + set(PSC_CODE_COUNT_OUTPUT_DIR "${ARG_OUTPUT_DIR}") + set(PSC_CODE_COUNT_TOKEI "${ARG_TOKEI_EXECUTABLE}") + set(PSC_CODE_COUNT_CLOC "${ARG_CLOC_EXECUTABLE}") + set(script_dir "${CMAKE_CURRENT_BINARY_DIR}/psc_metrics_scripts/${target_name}") + file(MAKE_DIRECTORY "${script_dir}") + set(script "${script_dir}/${target_name}_code_count.ps1") + configure_file("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/Code_Count.ps1.in" "${script}" @ONLY) + add_custom_target(${target_name} + COMMAND pwsh -NoProfile -ExecutionPolicy Bypass -File "${script}" + VERBATIM + ) + set(${target_name}_OUTPUT_DIR "${ARG_OUTPUT_DIR}" PARENT_SCOPE) +endfunction() diff --git a/lizard/Install.ps1 b/lizard/Install.ps1 new file mode 100644 index 0000000..b628ade --- /dev/null +++ b/lizard/Install.ps1 @@ -0,0 +1,20 @@ +param( + [string]$Version = "1.23.0", + [string]$PythonVersion = "3.10" +) +$ErrorActionPreference = "Stop" +$package = if ($Version) { "lizard==$Version" } else { "lizard" } +& py "-$PythonVersion" -m pip install --user --upgrade $package +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} +$python = & py "-$PythonVersion" -c "import sys; print(sys.executable)" +$scripts = Join-Path (Split-Path (Split-Path $python -Parent) -Parent) "Scripts" +$lizard = Join-Path $env:APPDATA "Python\Python$($PythonVersion.Replace('.', ''))\Scripts\lizard.exe" +if (!(Test-Path -LiteralPath $lizard)) { + $lizard = Join-Path $scripts "lizard.exe" +} +if (!(Test-Path -LiteralPath $lizard)) { + throw "lizard executable was not found" +} +& $lizard --version diff --git a/lizard/Lizard.ps1.in b/lizard/Lizard.ps1.in new file mode 100644 index 0000000..65eaaf4 --- /dev/null +++ b/lizard/Lizard.ps1.in @@ -0,0 +1,71 @@ +$ErrorActionPreference = "Stop" +$inputDirs = @PSC_LIZARD_INPUT_DIRS@ +$excludeDirs = @PSC_LIZARD_EXCLUDE_DIRS@ +$outputDir = "@PSC_LIZARD_OUTPUT_DIR@" +$configuredLizard = "@PSC_LIZARD_EXECUTABLE@" +$topN = [int]"@PSC_LIZARD_TOP_N@" +$ccnWarning = [int]"@PSC_LIZARD_CCN_WARNING@" +$lengthWarning = [int]"@PSC_LIZARD_LENGTH_WARNING@" +$argumentWarning = [int]"@PSC_LIZARD_ARGUMENT_WARNING@" +function Resolve-Lizard { + if ($configuredLizard -and (Test-Path -LiteralPath $configuredLizard)) { + return (Resolve-Path -LiteralPath $configuredLizard).Path + } + $command = Get-Command lizard -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + return $command.Source + } + $fallback = Join-Path $env:APPDATA "Python\Python310\Scripts\lizard.exe" + if (Test-Path -LiteralPath $fallback) { + return (Resolve-Path -LiteralPath $fallback).Path + } + throw "lizard executable was not found" +} +$lizard = Resolve-Lizard +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null +$baseArguments = @("-C", $ccnWarning, "-L", $lengthWarning, "-a", $argumentWarning) +foreach ($exclude in $excludeDirs) { + $pattern = (Join-Path $exclude "*").Replace("\", "/") + $baseArguments += @("-x", $pattern) +} +$csvPath = Join-Path $outputDir "lizard_raw.csv" +$xmlPath = Join-Path $outputDir "lizard.xml" +$textPath = Join-Path $outputDir "lizard.txt" +& $lizard @baseArguments "--csv" @inputDirs | Set-Content -LiteralPath $csvPath -Encoding UTF8 +$csvExitCode = $LASTEXITCODE +& $lizard @baseArguments "--xml" @inputDirs | Set-Content -LiteralPath $xmlPath -Encoding UTF8 +& $lizard @baseArguments @inputDirs | Set-Content -LiteralPath $textPath -Encoding UTF8 +$headers = @("NLOC", "CCN", "TokenCount", "ParameterCount", "Length", "Location", "File", "QualifiedName", "Signature", "StartLine", "EndLine") +$records = @(Get-Content -LiteralPath $csvPath | ConvertFrom-Csv -Header $headers | ForEach-Object { + [pscustomobject]@{ + NLOC = [int]$_.NLOC + CCN = [int]$_.CCN + TokenCount = [int]$_.TokenCount + ParameterCount = [int]$_.ParameterCount + Length = [int]$_.Length + File = $_.File + Function = $_.QualifiedName + Signature = $_.Signature + StartLine = [int]$_.StartLine + EndLine = [int]$_.EndLine + } +}) +$cyclomaticTop = @($records | Sort-Object @{Expression = "CCN"; Descending = $true}, @{Expression = "NLOC"; Descending = $true} | Select-Object -First $topN) +$lengthTop = @($records | Sort-Object @{Expression = "NLOC"; Descending = $true}, @{Expression = "CCN"; Descending = $true} | Select-Object -First $topN) +$parameterTop = @($records | Sort-Object @{Expression = "ParameterCount"; Descending = $true}, @{Expression = "CCN"; Descending = $true} | Select-Object -First $topN) +$cyclomaticTop | Export-Csv -LiteralPath (Join-Path $outputDir "cyclomatic_complexity.csv") -NoTypeInformation -Encoding UTF8 +$lengthTop | Export-Csv -LiteralPath (Join-Path $outputDir "function_length.csv") -NoTypeInformation -Encoding UTF8 +$parameterTop | Export-Csv -LiteralPath (Join-Path $outputDir "parameter_count.csv") -NoTypeInformation -Encoding UTF8 +$summary = [ordered]@{ + tool = "lizard" + version = (& $lizard --version | Select-Object -First 1).Trim() + tool_exit_code = $csvExitCode + functions = $records.Count + average_ccn = if ($records.Count) { [Math]::Round(($records | Measure-Object CCN -Average).Average, 3) } else { 0 } + thresholds = [ordered]@{ ccn = $ccnWarning; length = $lengthWarning; arguments = $argumentWarning } + cyclomatic_top = $cyclomaticTop + function_length_top = $lengthTop + parameter_top = $parameterTop +} +[IO.File]::WriteAllText((Join-Path $outputDir "summary.json"), ($summary | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) +Write-Output "Lizard report: $outputDir" diff --git a/lizard/export.cmake b/lizard/export.cmake new file mode 100644 index 0000000..44e4407 --- /dev/null +++ b/lizard/export.cmake @@ -0,0 +1 @@ +include(${CMAKE_CURRENT_LIST_DIR}/main.cmake) diff --git a/lizard/main.cmake b/lizard/main.cmake new file mode 100644 index 0000000..7f98bf3 --- /dev/null +++ b/lizard/main.cmake @@ -0,0 +1,53 @@ +function(psc_lizard_ps_array out_value) + set(values) + foreach(item IN LISTS ARGN) + string(REPLACE "'" "''" escaped "${item}") + list(APPEND values "'${escaped}'") + endforeach() + string(REPLACE ";" "," value "${values}") + set(${out_value} "@(${value})" PARENT_SCOPE) +endfunction() +function(psc_add_lizard_target target_name) + set(options) + set(oneValueArgs OUTPUT_DIR LIZARD_EXECUTABLE TOP_N CCN_WARNING FUNCTION_LENGTH_WARNING ARGUMENT_WARNING) + set(multiValueArgs INPUT_DIRS EXCLUDE_DIRS) + cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + if (NOT ARG_OUTPUT_DIR) + set(ARG_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/metrics/${target_name}/lizard") + endif () + if (NOT ARG_LIZARD_EXECUTABLE) + set(ARG_LIZARD_EXECUTABLE "$ENV{APPDATA}/Python/Python310/Scripts/lizard.exe") + endif () + if (NOT ARG_TOP_N) + set(ARG_TOP_N 50) + endif () + if (NOT ARG_CCN_WARNING) + set(ARG_CCN_WARNING 15) + endif () + if (NOT ARG_FUNCTION_LENGTH_WARNING) + set(ARG_FUNCTION_LENGTH_WARNING 200) + endif () + if (NOT ARG_ARGUMENT_WARNING) + set(ARG_ARGUMENT_WARNING 8) + endif () + if (NOT ARG_INPUT_DIRS) + set(ARG_INPUT_DIRS "${CMAKE_SOURCE_DIR}") + endif () + psc_lizard_ps_array(PSC_LIZARD_INPUT_DIRS ${ARG_INPUT_DIRS}) + psc_lizard_ps_array(PSC_LIZARD_EXCLUDE_DIRS ${ARG_EXCLUDE_DIRS}) + set(PSC_LIZARD_OUTPUT_DIR "${ARG_OUTPUT_DIR}") + set(PSC_LIZARD_EXECUTABLE "${ARG_LIZARD_EXECUTABLE}") + set(PSC_LIZARD_TOP_N "${ARG_TOP_N}") + set(PSC_LIZARD_CCN_WARNING "${ARG_CCN_WARNING}") + set(PSC_LIZARD_LENGTH_WARNING "${ARG_FUNCTION_LENGTH_WARNING}") + set(PSC_LIZARD_ARGUMENT_WARNING "${ARG_ARGUMENT_WARNING}") + set(script_dir "${CMAKE_CURRENT_BINARY_DIR}/psc_metrics_scripts/${target_name}") + file(MAKE_DIRECTORY "${script_dir}") + set(script "${script_dir}/${target_name}_lizard.ps1") + configure_file("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/Lizard.ps1.in" "${script}" @ONLY) + add_custom_target(${target_name} + COMMAND pwsh -NoProfile -ExecutionPolicy Bypass -File "${script}" + VERBATIM + ) + set(${target_name}_OUTPUT_DIR "${ARG_OUTPUT_DIR}" PARENT_SCOPE) +endfunction()