From bea9c089e46a943754e10f72658747e486ffc2bd Mon Sep 17 00:00:00 2001 From: doe300 Date: Sat, 5 Sep 2026 09:40:41 +0200 Subject: [PATCH 1/3] Added base functionality to parse SARIF files and corresponding tests Allows to parse SARIF [1] static analysis result files, which is later used for clang-analyze integration. [1] https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html --- src/CMakeLists.txt | 1 + src/sarif.cpp | 336 +++++++++++++++++ src/sarif.hpp | 121 ++++++ tests/CMakeLists.txt | 4 + tests/sarif_test.cpp | 72 ++++ tests/sarif_test_files/example_run.sarif.json | 353 ++++++++++++++++++ .../sarif_test_files/example_run2.sarif.json | 111 ++++++ 7 files changed, 998 insertions(+) create mode 100644 src/sarif.cpp create mode 100644 src/sarif.hpp create mode 100644 tests/sarif_test.cpp create mode 100644 tests/sarif_test_files/example_run.sarif.json create mode 100644 tests/sarif_test_files/example_run2.sarif.json diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 857afa1..b0361dd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -16,6 +16,7 @@ set(JUCI_SHARED_FILES meson.cpp mutex.cpp project_build.cpp + sarif.cpp snippets.cpp source.cpp source_base.cpp diff --git a/src/sarif.cpp b/src/sarif.cpp new file mode 100644 index 0000000..a19f9d4 --- /dev/null +++ b/src/sarif.cpp @@ -0,0 +1,336 @@ +#include "sarif.hpp" +#include "utility.hpp" + +#define JSON_DISABLE_ENUM_SERIALIZATION 1 +#include "nlohmann/json.hpp" + +#include +#include +#include +#include + +namespace SARIF { + + std::string Message::to_string() const { + if(arguments.empty()) + return text; + + auto final_text = text; + for(std::size_t i = 0; i < arguments.size(); ++i) { + auto pattern = "{" + std::to_string(i) + "}"; + for(auto pos = final_text.find(pattern); pos < final_text.size(); pos = final_text.find(pattern)) { + final_text.replace(pos, pattern.size(), arguments[i]); + } + } + return final_text; + } + + void from_json(const nlohmann::json &json, Message &message) { + message.text = json.value("text", ""); + message.arguments = json.value("arguments", std::vector{}); + } + + bool Region::operator<(const Region &other) const noexcept { +#define COMPARE(one, other) \ + if(one < other) \ + return true; \ + else if(one > other) \ + return false; + + COMPARE(start_line, other.start_line) + COMPARE(start_column, other.start_column) + COMPARE(end_line, other.end_line) + COMPARE(end_column, other.end_column) + return false; + +#undef COMPARE + } + + void from_json(const nlohmann::json &json, Region ®ion) { + region.start_line = json.value("startLine", 0U); + region.end_line = json.value("endLine", region.start_line); + region.start_column = json.value("startColumn", 1U); + region.end_column = json.value("endColumn", region.start_column); + + region.snippet = json.value("snippet", nlohmann::json::object()).value("text", ""); + } + + void from_json(const nlohmann::json &json, Location &location) { + location.message = json.value("message", Message{}); + + auto physicalLocation = json.value("physicalLocation", nlohmann::json::object()); + location.region = physicalLocation.value("region", Region{}); + auto contextRegion = physicalLocation.value("contextRegion", nlohmann::json{}); + if(!contextRegion.empty()) + location.context_region = contextRegion.get(); + + auto uri = physicalLocation.value("artifactLocation", nlohmann::json::object()).value("uri", ""); + if(starts_with(uri, "file://")) + location.artifact_location = boost::filesystem::path{uri.substr(7)}; + } + + using Importance = ThreadFlowLocation::Importance; + NLOHMANN_JSON_SERIALIZE_ENUM(Importance, {{Importance::important, "important"}, {Importance::essential, "essential"}, {Importance::unimportant, "unimportant"}}) + + void from_json(const nlohmann::json &json, ThreadFlowLocation &location) { + location.importance = json.value("importance", ThreadFlowLocation::Importance::important); + location.location = json.value("location", Location{}); + } + + void from_json(const nlohmann::json &json, ThreadFlow &flow) { + flow.locations = json.value("locations", std::vector{}); + } + + void from_json(const nlohmann::json &json, CodeFlow &flow) { + flow.thread_flows = json.value("threadFlows", std::vector{}); + } + + using Level = Result::Level; + NLOHMANN_JSON_SERIALIZE_ENUM(Level, {{Level::none, "none"}, {Level::error, "error"}, {Level::note, "note"}, {Level::warning, "warning"}}) + + std::string Result::get_message(const Location &location, bool markdown) const { + auto text = location.message.to_string(); + if(text.empty()) + text = message.to_string(); + + if(markdown && !rule_id.empty() && !help_uri.empty()) + text.append(" [(clang-analyze: ") + .append(rule_id) + .append(")](") + .append(help_uri) + .append(")"); + else if(!rule_id.empty()) + text.append(" (clang-analyze: ") + .append(rule_id) + .append(")"); + + return text; + } + + std::string to_string(Result::Level level) { + switch(level) { + case Result::Level::error: + return "error"; + case Result::Level::none: + return "none"; + case Result::Level::note: + return "note"; + case Result::Level::warning: + return "warning"; + } + return "(unknown)"; + } + + void from_json(const nlohmann::json &json, Result &result) { + result.code_flows = json.value("codeFlows", std::vector{}); + result.level = json.value("level", Result::Level::warning); + result.locations = json.value("locations", std::vector{}); + result.message = json.value("message", Message{}); + result.rule_id = json.value("ruleId", ""); + } + + void from_json(const nlohmann::json &json, ReportingDescriptor &descriptor) { + descriptor.id = json.value("id", ""); + descriptor.name = json.value("name", ""); + descriptor.help_uri = json.value("helpUri", ""); + } + + void from_json(const nlohmann::json &json, ToolComponent &component) { + component.rules = json.value("rules", std::vector{}); + } + + void from_json(const nlohmann::json &json, Tool &tool) { + tool.driver = json.value("driver", ToolComponent{}); + } + + void from_json(const nlohmann::json &json, Run &run) { + run.results = json.value("results", std::vector{}); + run.tool = json.value("tool", Tool{}); + } + + void from_json(const nlohmann::json &json, Log &log) { + log.runs = json.value("runs", std::vector{}); + } + + static void recursive_add_context(nlohmann::json &json) { + if(json.is_object()) { + auto file_uri = json.value("artifactLocation", nlohmann::json::object()).value("uri", ""); + auto region = json.value("region", Region{}); + if(starts_with(file_uri, "file://") && region.start_line && region.end_line) { + const std::size_t CONTEXT_LINES = 3; + auto context_start_line = std::max(region.start_line, CONTEXT_LINES) - CONTEXT_LINES; + auto context_num_desired_lines = region.end_line + 1 /* end is inclusive */ - region.start_line + 2 * CONTEXT_LINES; + + std::ifstream fis{file_uri.substr(7 /* "file://" */)}; + std::string line; + std::string context; + std::string text; + uint32_t context_num_lines = 0; + + for(uint32_t i = 1; i < context_start_line; ++i) + std::getline(fis, line); + for(uint32_t i = 0; i < context_num_desired_lines; ++i) { + if(std::getline(fis, line)) { + if(!context.empty()) + context.append("\n"); + context.append(line); + ++context_num_lines; + + if(i >= CONTEXT_LINES && i + CONTEXT_LINES < context_num_desired_lines) { + if(i + CONTEXT_LINES + 1 == context_num_desired_lines && region.end_column > 1) + line = line.substr(0, region.end_column - 1U); + if(i == CONTEXT_LINES && region.start_column) + line = line.substr(region.start_column - 1); + if(!text.empty()) + text.append("\n"); + text.append(line); + } + } + } + + if(!context.empty()) + json["contextRegion"] = nlohmann::json::object({ + {"startLine", context_start_line}, + {"endLine", context_start_line + context_num_lines - 1}, + {"snippet", nlohmann::json::object({{"text", context}})}, + }); + if(!text.empty() && !json["region"].contains("snippet")) + json["region"]["snippet"] = nlohmann::json::object({{"text", text}}); + } + } + if(json.is_structured()) { + for(auto &entry : json) { + recursive_add_context(entry); + } + } + } + + static void recursive_remove_index(nlohmann::json &json) { + if(json.is_object()) { + auto it = json.find("index"); + if(it != json.end()) + json.erase(it); + } + if(json.is_structured()) { + for(auto &entry : json) { + recursive_remove_index(entry); + } + } + } + + std::vector merge_files(const boost::filesystem::path &output_file, std::vector &input_files) { + input_files.erase(std::remove_if(input_files.begin(), input_files.end(), [](const boost::filesystem::path &file) { + return !boost::filesystem::exists(file); + }), + input_files.end()); + + // Deduplicate results added by multiple analyzes, e.g. for finding in headers + std::unordered_set added_results; + + if(input_files.empty()) { + boost::filesystem::remove(output_file); + return {}; + } + + std::ifstream fis{input_files.front().string()}; + auto output = nlohmann::json::parse(fis); + recursive_remove_index(output); + auto &runs = output["runs"]; + if(runs.is_null() || (runs.size() == 1 && runs[0]["results"].empty())) + runs = nlohmann::json::array(); + + auto it = input_files.begin(); + for(++it; it < input_files.end(); ++it) { + fis = std::ifstream{it->string()}; + auto part = nlohmann::json::parse(fis); + auto it = part.find("runs"); + if(it != part.end() && it->is_array()) { + for(auto &run : *it) { + auto &results = run["results"]; + for(auto result_it = results.begin(); result_it != results.end();) { + // Remove some values which we don't care about and might differ for otherwise identical results + auto it = result_it->find("ruleIndex"); + if(it != result_it->end()) + result_it->erase(it); + recursive_remove_index(*result_it); + + if(added_results.find(*result_it) != added_results.end()) + result_it = results.erase(result_it); + else { + added_results.emplace(*result_it); + ++result_it; + } + } + + if(results.empty()) + // Do not merge runs without findings + continue; + + runs.insert(runs.end(), std::move(run)); + } + } + } + + recursive_add_context(output); + + std::ofstream fos{output_file.string()}; + fos << std::setw(2) << output << std::endl; + + std::vector results{}; + for(const auto &run : runs) { + auto new_results = run.value("results", std::vector{}); + results.insert(results.end(), std::make_move_iterator(new_results.begin()), std::make_move_iterator(new_results.end())); + } + return results; + } + + std::vector results_for_file(const boost::filesystem::path &results_file, const boost::filesystem::path &source_file) { + if(!boost::filesystem::exists(results_file)) + return {}; + + const auto find_help_uri = [](const Run &run, const std::string &id) { + for(const auto &rule : run.tool.driver.rules) { + if(rule.id == id) + return rule.help_uri; + } + return std::string{}; + }; + + std::ifstream fis{results_file.string()}; + auto log = nlohmann::json::parse(fis, nullptr).get(); + + std::vector results; + for(auto &run : log.runs) { + for(auto result : run.results) { + if(std::any_of(result.locations.begin(), result.locations.end(), [&](const auto &loc) { + return loc.artifact_location == source_file; + })) { + result.help_uri = find_help_uri(run, result.rule_id); + results.push_back(std::move(result)); + continue; + } + + bool result_added = false; + for(auto &code_flow : result.code_flows) { + if(result_added) + break; + + for(auto &thread_flow : code_flow.thread_flows) { + if(result_added) + break; + + if(std::any_of(thread_flow.locations.begin(), thread_flow.locations.end(), [&](const auto &loc) { + return loc.location.artifact_location == source_file; + })) { + result.help_uri = find_help_uri(run, result.rule_id); + results.push_back(std::move(result)); + result_added = true; + break; + } + } + } + } + } + return results; + } +} // namespace SARIF diff --git a/src/sarif.hpp b/src/sarif.hpp new file mode 100644 index 0000000..4ff61a5 --- /dev/null +++ b/src/sarif.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include +#include + +#include +#include + +#include "nlohmann/json_fwd.hpp" + +namespace SARIF { + + struct Message { + std::string text; + std::vector arguments; + + std::string to_string() const; + }; + void from_json(const nlohmann::json &json, Message &message); + + struct Region { + std::size_t start_line; + std::size_t end_line; + std::size_t start_column; + std::size_t end_column; + std::string snippet; + + bool operator<(const Region &other) const noexcept; + }; + void from_json(const nlohmann::json &json, Region ®ion); + + struct Location { + Message message; + boost::filesystem::path artifact_location; + Region region; + boost::optional context_region; + }; + void from_json(const nlohmann::json &json, Location &location); + + struct ThreadFlowLocation { + enum class Importance { + important, + essential, + unimportant, + }; + + Importance importance; + Location location; + }; + void from_json(const nlohmann::json &json, ThreadFlowLocation &location); + + struct ThreadFlow { + std::vector locations; + }; + void from_json(const nlohmann::json &json, ThreadFlow &flow); + + struct CodeFlow { + std::vector thread_flows; + }; + void from_json(const nlohmann::json &json, CodeFlow &flow); + + struct Result { + enum class Level { + none, + note, + warning, + error, + }; + + Level level; + Message message; + std::vector locations; + std::vector code_flows; + std::string rule_id; + // Custom field + std::string help_uri; + + std::string get_message(const Location &location, bool markdown = false) const; + }; + std::string to_string(Result::Level level); + void from_json(const nlohmann::json &json, Result &result); + + struct ReportingDescriptor { + std::string id; + std::string name; + std::string help_uri; + }; + void from_json(const nlohmann::json &json, ReportingDescriptor &descriptor); + + struct ToolComponent { + std::vector rules; + }; + void from_json(const nlohmann::json &json, ToolComponent &component); + + struct Tool { + ToolComponent driver; + }; + void from_json(const nlohmann::json &json, Tool &tool); + + struct Run { + std::vector results; + Tool tool; + }; + void from_json(const nlohmann::json &json, Run &run); + + // According to Static Analysis Results Interchange Format (SARIF) specification version 2.1, + // see https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html + struct Log { + std::vector runs; + }; + void from_json(const nlohmann::json &json, Log &log); + + /// Merges the results of all input files. + /// Removes duplicate entries (e.g. findings in headers included in multiple files). + /// Adds a context region for each location where possible. + /// Returns the list of merged results. + std::vector merge_files(const boost::filesystem::path &output_file, std::vector &input_files); + + /// Returns the list of results for the given file. + std::vector results_for_file(const boost::filesystem::path &results_file, const boost::filesystem::path &source_file); +} // namespace SARIF diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e3f5d71..243ceb3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -99,6 +99,10 @@ if(BUILD_TESTING) add_executable(json_test json_test.cpp $) target_link_libraries(json_test juci_shared) add_test(json_test json_test) + + add_executable(sarif_test sarif_test.cpp) + target_link_libraries(sarif_test juci_shared) + add_test(sarif_test sarif_test) endif() if(BUILD_FUZZING) diff --git a/tests/sarif_test.cpp b/tests/sarif_test.cpp new file mode 100644 index 0000000..70ac26a --- /dev/null +++ b/tests/sarif_test.cpp @@ -0,0 +1,72 @@ +#include "sarif.hpp" + +#include "nlohmann/json.hpp" +#include + +#include + +int main() { + + const auto tests_path = boost::filesystem::canonical(JUCI_TESTS_PATH) / "sarif_test_files"; + std::vector example_files = { + tests_path / "example_run.sarif.json", + tests_path / "example_run2.sarif.json", + tests_path / "no_such_file.sarif.json", + }; + const auto output_file = tests_path / "merged.sarif.json"; + + boost::filesystem::remove(output_file); + g_assert(!boost::filesystem::exists(output_file)); + + auto results = SARIF::merge_files(output_file, example_files); + g_assert_cmpuint(results.size(), ==, 2U); + + results = SARIF::results_for_file(output_file, "/home/user/jucipp/src/source_base.cpp"); + g_assert_cmpuint(results.size(), ==, 1U); + const auto &result = results.front(); + g_assert(result.rule_id == "alpha.cplusplus.IteratorRange"); + g_assert(result.message.text == "Past-the-end iterator dereferenced"); + g_assert(result.message.arguments.empty()); + g_assert(result.level == SARIF::Result::Level::warning); + g_assert(result.help_uri == "https://clang.llvm.org/docs/analyzer/checkers.html#alpha-cplusplus-iteratorrange"); + + g_assert_cmpuint(result.locations.size(), ==, 1); + const auto &loc = result.locations.front(); + g_assert(loc.artifact_location == "/home/user/jucipp/src/source_base.cpp"); + g_assert_cmpuint(loc.region.start_line, ==, 1940); + g_assert_cmpuint(loc.region.end_line, ==, 1940); + g_assert_cmpuint(loc.region.start_column, ==, 35); + g_assert_cmpuint(loc.region.end_column, ==, 57); + g_assert(loc.message.text.empty()); + g_assert(loc.message.arguments.empty()); + g_assert(result.get_message(loc, false) == "Past-the-end iterator dereferenced (clang-analyze: alpha.cplusplus.IteratorRange)"); + g_assert(result.get_message(loc, true) == "Past-the-end iterator dereferenced [(clang-analyze: alpha.cplusplus.IteratorRange)](https://clang.llvm.org/docs/analyzer/checkers.html#alpha-cplusplus-iteratorrange)"); + + g_assert_cmpuint(result.code_flows.size(), ==, 1); + const auto &codeFlow = result.code_flows.front(); + + g_assert_cmpuint(codeFlow.thread_flows.size(), ==, 1); + const auto &threadFlow = codeFlow.thread_flows.front(); + + g_assert_cmpuint(threadFlow.locations.size(), ==, 14); + + const auto &firstLoc = threadFlow.locations.front(); + g_assert(firstLoc.importance == SARIF::ThreadFlowLocation::Importance::unimportant); + g_assert(firstLoc.location.message.text == "Taking false branch"); + g_assert(firstLoc.location.message.arguments.empty()); + g_assert(firstLoc.location.artifact_location == "/home/user/jucipp/src/source_base.cpp"); + g_assert(firstLoc.location.region.start_line == 1875); + g_assert(firstLoc.location.region.end_line == 1875); + g_assert(firstLoc.location.region.start_column == 3); + g_assert(firstLoc.location.region.end_column == 3); + + const auto &lastLoc = threadFlow.locations.back(); + g_assert(lastLoc.importance == SARIF::ThreadFlowLocation::Importance::essential); + g_assert(lastLoc.location.message.text == "Past-the-end iterator dereferenced"); + g_assert(lastLoc.location.message.arguments.empty()); + g_assert(lastLoc.location.artifact_location == "/home/user/jucipp/src/source_base.cpp"); + g_assert(lastLoc.location.region.start_line == 1940); + g_assert(lastLoc.location.region.end_line == 1940); + g_assert(lastLoc.location.region.start_column == 35); + g_assert(lastLoc.location.region.end_column == 57); +} diff --git a/tests/sarif_test_files/example_run.sarif.json b/tests/sarif_test_files/example_run.sarif.json new file mode 100644 index 0000000..2e40249 --- /dev/null +++ b/tests/sarif_test_files/example_run.sarif.json @@ -0,0 +1,353 @@ +{ + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json", + "runs": [ + { + "artifacts": [ + { + "length": 75606, + "location": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "mimeType": "text/plain", + "roles": [ + "resultFile" + ] + } + ], + "columnKind": "unicodeCodePoints", + "results": [ + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "unimportant", + "location": { + "message": { + "text": "Taking false branch" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 3, + "startColumn": 3, + "startLine": 1875 + } + } + } + }, + { + "importance": "important", + "location": { + "message": { + "text": "Assuming the condition is false" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 52, + "endLine": 1884, + "startColumn": 6, + "startLine": 1884 + } + } + } + }, + { + "importance": "unimportant", + "location": { + "message": { + "text": "Taking false branch" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 3, + "startColumn": 3, + "startLine": 1884 + } + } + } + }, + { + "importance": "unimportant", + "location": { + "message": { + "text": "'erase_line' is false" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 11, + "startColumn": 11, + "startLine": 1888 + } + } + } + }, + { + "importance": "unimportant", + "location": { + "message": { + "text": "Taking false branch" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 8, + "startColumn": 8, + "startLine": 1888 + } + } + } + }, + { + "importance": "unimportant", + "location": { + "message": { + "text": "'erase_word' is false" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 11, + "startColumn": 11, + "startLine": 1894 + } + } + } + }, + { + "importance": "unimportant", + "location": { + "message": { + "text": "Left side of '&&' is false" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 22, + "startColumn": 22, + "startLine": 1894 + } + } + } + }, + { + "importance": "important", + "location": { + "message": { + "text": "Assuming the condition is true" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 46, + "endLine": 1914, + "startColumn": 6, + "startLine": 1914 + } + } + } + }, + { + "importance": "unimportant", + "location": { + "message": { + "text": "Taking true branch" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 3, + "startColumn": 3, + "startLine": 1914 + } + } + } + }, + { + "importance": "essential", + "location": { + "message": { + "text": "Calling 'BaseView::select_snippet_parameter'" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 31, + "endLine": 1915, + "startColumn": 5, + "startLine": 1915 + } + } + } + }, + { + "importance": "unimportant", + "location": { + "message": { + "text": "Loop condition is false. Execution continues on line 1926" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 3, + "startColumn": 3, + "startLine": 1919 + } + } + } + }, + { + "importance": "important", + "location": { + "message": { + "text": "Assuming the condition is true" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 38, + "endLine": 1937, + "startColumn": 6, + "startLine": 1937 + } + } + } + }, + { + "importance": "unimportant", + "location": { + "message": { + "text": "Taking true branch" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 3, + "startColumn": 3, + "startLine": 1937 + } + } + } + }, + { + "importance": "essential", + "location": { + "message": { + "text": "Past-the-end iterator dereferenced" + }, + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 57, + "endLine": 1940, + "startColumn": 35, + "startLine": 1940 + } + } + } + } + ] + } + ] + } + ], + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "index": 0, + "uri": "file:///home/user/jucipp/src/source_base.cpp" + }, + "region": { + "endColumn": 57, + "endLine": 1940, + "startColumn": 35, + "startLine": 1940 + } + } + } + ], + "message": { + "text": "Past-the-end iterator dereferenced" + }, + "ruleId": "alpha.cplusplus.IteratorRange", + "ruleIndex": 0 + } + ], + "tool": { + "driver": { + "fullName": "clang static analyzer", + "informationUri": "https://clang.llvm.org/docs/UsersManual.html", + "language": "en-US", + "name": "clang", + "rules": [ + { + "defaultConfiguration": { + "enabled": true, + "level": "warning", + "rank": -1 + }, + "fullDescription": { + "text": "Check for iterators used outside their valid ranges" + }, + "helpUri": "https://clang.llvm.org/docs/analyzer/checkers.html#alpha-cplusplus-iteratorrange", + "id": "alpha.cplusplus.IteratorRange", + "name": "alpha.cplusplus.IteratorRange" + } + ], + "version": "clang version 21.1.8 (Fedora 21.1.8-4.fc43)" + } + } + } + ], + "version": "2.1.0" +} diff --git a/tests/sarif_test_files/example_run2.sarif.json b/tests/sarif_test_files/example_run2.sarif.json new file mode 100644 index 0000000..3017b38 --- /dev/null +++ b/tests/sarif_test_files/example_run2.sarif.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json", + "runs": [ + { + "artifacts": [ + { + "length": 3814, + "location": { + "index": 0, + "uri": "file:///home/user/jucipp/src/config.hpp" + }, + "mimeType": "text/plain", + "roles": [ + "resultFile" + ] + } + ], + "columnKind": "unicodeCodePoints", + "results": [ + { + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "importance": "essential", + "location": { + "message": { + "text": "Excessive padding in 'class Config::Source' (35 padding bytes, where 3 is optimal). Optimal fields order: style, font, spellcheck_language, show_whitespace_characters, word_wrap, clang_format_style, clang_tidy_checks, documentation_searches, map_font_size, right_margin_position, pixels_between_lines, default_tab_size, tooltip_top_offset, clang_usages_threads, add_missing_newline_at_end_of_file_on_save, remove_trailing_whitespace_characters_on_save, format_style_on_save, format_style_on_save_if_style_file_found, smart_brackets, smart_inserts, show_map, show_git_diff, show_background_pattern, show_right_margin, auto_tab_char_and_size, default_tab_char, tab_indents_line, highlight_current_line, show_line_numbers, enable_multiple_cursors, auto_reload_changed_files, search_for_selection, clang_tidy_enable, clang_detailed_preprocessing_record, debug_place_cursor_at_stop, consider reordering the fields or adding explicit padding members" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "file:///home/user/jucipp/src/config.hpp" + }, + "contextRegion": { + "endLine": 65, + "snippet": { + "text": " std::vector clang_analyze_checks;\n };\n\n class Source {\n public:\n class DocumentationSearch {\n public:" + }, + "startLine": 59 + }, + "region": { + "endColumn": 9, + "startColumn": 9, + "startLine": 62 + } + } + } + } + ] + } + ] + } + ], + "level": "warning", + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "file:///home/user/jucipp/src/config.hpp" + }, + "contextRegion": { + "endLine": 65, + "snippet": { + "text": " std::vector clang_analyze_checks;\n };\n\n class Source {\n public:\n class DocumentationSearch {\n public:" + }, + "startLine": 59 + }, + "region": { + "endColumn": 9, + "startColumn": 9, + "startLine": 62 + } + } + } + ], + "message": { + "text": "Excessive padding in 'class Config::Source' (35 padding bytes, where 3 is optimal). Optimal fields order: style, font, spellcheck_language, show_whitespace_characters, word_wrap, clang_format_style, clang_tidy_checks, documentation_searches, map_font_size, right_margin_position, pixels_between_lines, default_tab_size, tooltip_top_offset, clang_usages_threads, add_missing_newline_at_end_of_file_on_save, remove_trailing_whitespace_characters_on_save, format_style_on_save, format_style_on_save_if_style_file_found, smart_brackets, smart_inserts, show_map, show_git_diff, show_background_pattern, show_right_margin, auto_tab_char_and_size, default_tab_char, tab_indents_line, highlight_current_line, show_line_numbers, enable_multiple_cursors, auto_reload_changed_files, search_for_selection, clang_tidy_enable, clang_detailed_preprocessing_record, debug_place_cursor_at_stop, consider reordering the fields or adding explicit padding members" + }, + "ruleId": "optin.performance.Padding" + } + ], + "tool": { + "driver": { + "fullName": "clang static analyzer", + "informationUri": "https://clang.llvm.org/docs/UsersManual.html", + "language": "en-US", + "name": "clang", + "rules": [ + { + "defaultConfiguration": { + "enabled": true, + "level": "warning", + "rank": -1 + }, + "fullDescription": { + "text": "Check for excessively padded structs." + }, + "helpUri": "https://clang.llvm.org/docs/analyzer/checkers.html#optin-performance-padding", + "id": "optin.performance.Padding", + "name": "optin.performance.Padding" + } + ], + "version": "clang version 21.1.8 (Fedora 21.1.8-4.fc43)" + } + } + } + ], + "version": "2.1.0" +} From 3ea04e8715a41065a9d5afeba5452d9781056a11 Mon Sep 17 00:00:00 2001 From: doe300 Date: Sat, 5 Sep 2026 09:46:08 +0200 Subject: [PATCH 2/3] Added project menu entry to run static code analysis This is currently only implemented for clang-based projects, where clang --analyze is run for all source code files in an associated compile_commands.json file. --- src/config.cpp | 11 ++- src/config.hpp | 1 + src/menu.cpp | 4 ++ src/project.cpp | 156 ++++++++++++++++++++++++++++++++++++++++ src/project.hpp | 2 + src/window.cpp | 13 ++++ tests/stubs/project.cpp | 2 + 7 files changed, 188 insertions(+), 1 deletion(-) diff --git a/src/config.cpp b/src/config.cpp index 86f3954..21035ca 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -198,6 +198,13 @@ void Config::read(const JSON &cfg) { str.erase(str.begin()); project.open_with_default_application.emplace_back(std::move(str)); } + ss = std::stringstream(project_json.string("clang_analyze_checks")); + project.clang_analyze_checks.clear(); + while(getline(ss, str, ',')) { + while(!str.empty() && str.front() == ' ') + str.erase(str.begin()); + project.clang_analyze_checks.emplace_back(std::move(str)); + } auto terminal_json = cfg.object("terminal"); terminal.history_size = terminal_json.integer("history_size", JSON::ParseOptions::accept_string); @@ -361,7 +368,9 @@ std::string Config::default_config() { "python_command": "python -u", "markdown_command": "grip -b", "open_with_default_application_comment": "Comma-separated list of file extensions that should be opened with system default applications", - "open_with_default_application": ".pdf,.png" + "open_with_default_application": ".pdf,.png", + "clang_analyze_checks_comment": "Comma-separated list of checkers to enable for the clang static analyzer", + "clang_analyze_checks": "core,cplusplus,deadcode,nullability,security,unix,osx" }, "keybindings": { "preferences": "comma", diff --git a/src/config.hpp b/src/config.hpp index eda7a29..4b1e30b 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -56,6 +56,7 @@ public: std::string python_command; std::string markdown_command; std::vector open_with_default_application; + std::vector clang_analyze_checks; }; class Source { diff --git a/src/menu.cpp b/src/menu.cpp index 46a9b72..11a184d 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -431,6 +431,10 @@ const Glib::ustring menu_xml = R"RAW( _Recreate _Build app.project_recreate_build + + Run Static Analyzer + app.project_static_analyze +
diff --git a/src/project.cpp b/src/project.cpp index f041dcb..4b95c57 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -13,8 +13,11 @@ #ifdef JUCI_ENABLE_DEBUG #include "debug_lldb.hpp" #endif +#include "compile_commands.hpp" #include "ctags.hpp" +#include "dialog.hpp" #include "info.hpp" +#include "sarif.hpp" #include "snippets.hpp" #include "source_clang.hpp" #include "usages_clang.hpp" @@ -228,6 +231,10 @@ void Project::Base::recreate_build() { Info::get().print("Could not find a supported project"); } +void Project::Base::analyze() { + Info::get().print("Could not find a supported project"); +} + void Project::Base::show_symbols() { Ctags ctags(get_preferably_view_folder()); if(!ctags) { @@ -859,6 +866,155 @@ void Project::Clang::recreate_build() { } } +void Project::Clang::analyze() { + auto default_build_path = build->get_default_path(); + if(default_build_path.empty() || !build->update_default()) + return; + + compiling = true; + + if(Config::get().terminal.clear_on_compile) + Terminal::get().clear(); + Terminal::get() + .print("\e[37mAnalyzing project: " + filesystem::get_short_path(build->project_path).string() + "\e[m\n"); + + CompileCommands db{default_build_path}; + if(db.commands.empty()) + return; + + + const auto base_cmd = [] { + std::vector analyzer_cmd = {"clang++", "--analyze", "-Xanalyzer", "-analyzer-output=sarif"}; + for(const auto &checker : Config::get().project.clang_analyze_checks) { + analyzer_cmd.push_back("-Xanalyzer"); + analyzer_cmd.push_back("-analyzer-checker=" + checker); + } + return analyzer_cmd; + }(); + + std::vector result_files; + std::vector> command_lines; + for(const auto &file : db.commands) { + auto output_file = file.directory / (file.file.filename().string() + ".sarif.json"); + auto cmd = base_cmd; + cmd.push_back("-o"); + cmd.push_back(output_file.string()); + + auto compile_args = CompileCommands::get_arguments(build->get_default_path(), file.file); + for(const auto &arg : compile_args) { + cmd.push_back(arg); + } + cmd.push_back(file.file.string()); + + std::stringstream command_line; + for(const auto &arg : cmd) { + command_line << std::quoted(arg) << ' '; + } + command_lines.push_back(std::make_pair(file.file.string(), command_line.str())); + result_files.push_back(output_file); + } + + std::size_t num_finished_jobs = 0; + bool canceled = false; + Dialog::Message message( + "Clang-analyzing project, this can take a while...", [&canceled] { canceled = true; }, true /* progress */); + + const auto run_analyzer = [&](const std::string &file, const std::string &command_line) { + Terminal::get().print("Analyzing file: " + file + "...\n", true); + return Terminal::get().async_process(command_line, default_build_path, [&](int exit_status) { + // After one job finished, schedule the next or the final collection job + ++num_finished_jobs; + message.set_fraction(static_cast(num_finished_jobs) / static_cast(command_lines.size())); + if(exit_status > 0) { + Terminal::get().print("Analyzing file '" + file + "' returned " + std::to_string(exit_status) + ", aborting!\n", true); + // Do not start any new analyzers + canceled = true; + } + }); + }; + + const auto concurrent_processes = std::min(command_lines.size(), std::max(std::size_t{1}, std::size_t{std::thread::hardware_concurrency() - 1U})); + std::vector>> processes; + processes.reserve(concurrent_processes); + + // Schedule the first few jobs + auto next_job_it = command_lines.begin(); + for(std::size_t i = 0; !canceled && i < concurrent_processes; ++i) { + processes.emplace_back(next_job_it->first, run_analyzer(next_job_it->first, next_job_it->second)); + ++next_job_it; + } + + // Schedule remainder of jobs + while(next_job_it != command_lines.end()) { + if(canceled) { + for(auto &process : processes) { + if(process.second) + process.second->kill(); + } + break; + } + while(Gtk::Main::events_pending()) + Gtk::Main::iteration(); + + int exit_status = 0; + for(auto &process : processes) { + if(!process.second || process.second->try_get_exit_status(exit_status)) { + if(!canceled && next_job_it != command_lines.end()) { + process = std::make_pair(next_job_it->first, run_analyzer(next_job_it->first, next_job_it->second)); + ++next_job_it; + } + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + // Wait for all jobs to finish + while(!processes.empty()) { + int exit_status = 0; + for(auto it = processes.begin(); it != processes.end();) { + if(!it->second || it->second->try_get_exit_status(exit_status)) + it = processes.erase(it); + else + ++it; + } + while(Gtk::Main::events_pending()) + Gtk::Main::iteration(); + } + + // Combine results + if(!canceled) { + auto results = SARIF::merge_files(default_build_path / "clang-analyze.sarif.json", result_files); + if(!results.empty()) { + Terminal::get().print("Summary:\n", true); + for(const auto &result : results) { + for(const auto &location : result.locations) { + auto text = location.artifact_location.string() + ":"; + if(location.region.start_line) { + text.append(std::to_string(location.region.start_line)).push_back(':'); + if(location.region.start_column) + text.append(std::to_string(location.region.start_column)).push_back(':'); + } + text.append(" ").append(to_string(result.level)).append(": ").append(result.get_message(location)); + Terminal::get().print(text + "\n", true); + } + } + } + + for(const auto &file : result_files) { + boost::system::error_code error{}; + boost::filesystem::remove(file, error); + } + + Terminal::get() + .print("\e[37mFinished analyzing with " + std::to_string(results.size()) + " findings!" + "\e[m\n"); + } + + Project::compiling = false; + message.hide(); + + if(auto view = Notebook::get().get_current_view()) + view->soft_reparse(true); +} void Project::Markdown::compile_and_run(const boost::filesystem::path &file_path) { std::string command; diff --git a/src/project.hpp b/src/project.hpp index 178bbb8..4215bc3 100644 --- a/src/project.hpp +++ b/src/project.hpp @@ -58,6 +58,7 @@ namespace Project { virtual void compile(); virtual void compile_and_run(const boost::filesystem::path &file_path = {}); virtual void recreate_build(); + virtual void analyze(); void show_symbols(); @@ -112,6 +113,7 @@ namespace Project { void compile() override; void compile_and_run(const boost::filesystem::path &file_path = {}) override; void recreate_build() override; + void analyze() override; }; class Markdown : public Base { diff --git a/src/window.cpp b/src/window.cpp index a664181..518e2e4 100644 --- a/src/window.cpp +++ b/src/window.cpp @@ -1438,6 +1438,19 @@ void Window::set_menu_actions() { Project::current->recreate_build(); }); + menu.add_action("project_static_analyze", []() { + if(Project::compiling || Project::debugging) { + Info::get().print("Compile or debug in progress"); + return; + } + + Project::current = Project::create(); + + if(Config::get().project.save_on_compile_or_run) + Project::save_files(!Project::current->build->project_path.empty() ? Project::current->build->project_path : Project::get_preferably_view_folder()); + + Project::current->analyze(); + }); menu.add_action("project_run_command", [this]() { EntryBox::get().clear(); diff --git a/tests/stubs/project.cpp b/tests/stubs/project.cpp index 6902f51..d4a0e02 100644 --- a/tests/stubs/project.cpp +++ b/tests/stubs/project.cpp @@ -14,6 +14,8 @@ void Project::Base::compile_and_run(const boost::filesystem::path &file_path) {} void Project::Base::recreate_build() {} +void Project::Base::analyze() {} + std::pair Project::Base::debug_get_run_arguments() { return std::make_pair("", ""); } From 2872608b023ba57bcb0c811ad16457c9c512c6fe Mon Sep 17 00:00:00 2001 From: doe300 Date: Sat, 5 Sep 2026 09:48:00 +0200 Subject: [PATCH 3/3] Show clang-analyze results in source code view The analysis results are parsed from the SARIF file and are shown as diagnostics similar to compiler wranings. Also supports a simple algorithm for finding an original source code location for a diagnostic, if the source code changed since the analysis run. --- src/source_clang.cpp | 91 ++++++++++++++++++++++++++++++++++++++++++++ src/source_clang.hpp | 5 +++ 2 files changed, 96 insertions(+) diff --git a/src/source_clang.cpp b/src/source_clang.cpp index b3846f1..6b7f55e 100644 --- a/src/source_clang.cpp +++ b/src/source_clang.cpp @@ -12,6 +12,7 @@ #include "documentation.hpp" #include "filesystem.hpp" #include "info.hpp" +#include "sarif.hpp" #include "selection_dialog.hpp" #include "usages_clang.hpp" #include "utility.hpp" @@ -171,6 +172,16 @@ void Source::ClangViewParse::parse(size_t count) { for(auto &token : *clang_tokens) clang_tokens_offsets.emplace_back(token.get_source_range().get_offsets()); clang_diagnostics = clang_tu->get_diagnostics(); + + try { + auto analyze_results_file = Project::Build::create(this->file_path)->get_default_path() / "clang-analyze.sarif.json"; + analyze_diagnostics = SARIF::results_for_file(analyze_results_file, file_path); + } + catch(const std::exception &err) { + analyze_diagnostics.clear(); + Terminal::get().async_print("\e[31mError\e[m: Parsing clang-analyze results failed: " + std::string{err.what()} + ".\n", true); + } + parse_mutex.unlock(); dispatcher.post([this, count] { if(count != parse_count || parse_state != ParseState::processing) @@ -525,6 +536,86 @@ void Source::ClangViewParse::update_diagnostics() { } } + std::map, std::set> added_messages; + const auto add_tooltip_for_location = [this, &added_messages](const SARIF::Location &location, const SARIF::Result &result) { + auto text = result.get_message(location, true /* markdown */); + int line = location.region.start_line - 1; + if(line < 0 || line >= get_buffer()->get_line_count()) + line = get_buffer()->get_line_count() - 1; + auto start = get_iter_at_line_end(line); + int offset = location.region.start_column - 1; + if(offset >= 0 && offset < start.get_line_index()) + start = get_buffer()->get_iter_at_line_offset(line, offset); + if(start.ends_line()) { + while(!start.is_start() && start.ends_line()) + start.backward_char(); + } + + line = location.region.end_line - 1; + if(line < 0 || line >= get_buffer()->get_line_count()) + line = get_buffer()->get_line_count() - 1; + auto end = get_iter_at_line_end(line); + offset = location.region.end_column - 1; + if(offset >= 0 && offset < end.get_line_index()) + end = get_buffer()->get_iter_at_line_offset(line, offset); + + + // Check whether the source actually still matches the scan result + auto match = get_buffer()->get_text(start, end); + if(!location.region.snippet.empty() && match != location.region.snippet) { + bool context_found = false; + if(location.context_region && !location.context_region->snippet.empty()) { + // Try to find the context in case the matching source was only moved + Gtk::TextIter match_start, match_end; + if(start.forward_search(location.context_region->snippet, static_cast(0), match_start, match_end)) + context_found = true; + else if(end.backward_search(location.context_region->snippet, static_cast(0), match_start, match_end)) + context_found = true; + else if(get_buffer()->begin().forward_search(location.context_region->snippet, static_cast(0), match_start, match_end)) + context_found = true; + + if(context_found) { + match_start.forward_search(location.region.snippet, static_cast(0), start, end, match_end); + } + } + text.append(" (Source modified since scan, match ").append(context_found ? "may be" : "is").append(" inaccurate)"); + } + + auto added_it = added_messages.find(std::make_pair(start, end)); + if(added_it != added_messages.end() && added_it->second.find(text) != added_it->second.end()) + return false; + + diagnostic_offsets.emplace(start.get_offset()); + added_messages[std::make_pair(start, end)].emplace(text); + add_diagnostic_tooltip(start, end, result.level == SARIF::Result::Level::error, [text](Tooltip &tooltip) { + tooltip.insert_markdown(text); + }); + return true; + }; + + for(const auto &diagnostic : analyze_diagnostics) { + for(const auto &location : diagnostic.locations) { + if(location.artifact_location == file_path) { + if(add_tooltip_for_location(location, diagnostic)) { + num_warnings += diagnostic.level == SARIF::Result::Level::warning; + num_errors += diagnostic.level == SARIF::Result::Level::error; + } + } + } + for(const auto &code_flow : diagnostic.code_flows) { + for(const auto &thread_flow : code_flow.thread_flows) { + for(const auto &entry : thread_flow.locations) { + if(entry.location.artifact_location == file_path && entry.importance != SARIF::ThreadFlowLocation::Importance::unimportant) { + if(add_tooltip_for_location(entry.location, diagnostic)) { + num_warnings += diagnostic.level == SARIF::Result::Level::warning; + num_errors += diagnostic.level == SARIF::Result::Level::error; + } + } + } + } + } + } + status_diagnostics = std::make_tuple(num_warnings, num_errors, num_fix_its); if(update_status_diagnostics) update_status_diagnostics(this); diff --git a/src/source_clang.hpp b/src/source_clang.hpp index 78fd573..17eb50c 100644 --- a/src/source_clang.hpp +++ b/src/source_clang.hpp @@ -11,6 +11,10 @@ #include #include +namespace SARIF { + struct Result; +} // namespace SARIF + namespace Source { class ClangViewParse : public View { protected: @@ -57,6 +61,7 @@ namespace Source { void update_diagnostics() REQUIRES(parse_mutex); std::vector clang_diagnostics GUARDED_BY(parse_mutex); + std::vector analyze_diagnostics GUARDED_BY(parse_mutex); /// Removes for instance ::__1:: and ::__cxx11:: from type void remove_internal_namespaces(std::string &type);