diff --git a/implementations/Crafter.Build-Lint.cpp b/implementations/Crafter.Build-Lint.cpp index 45e0d68..8571e29 100644 --- a/implementations/Crafter.Build-Lint.cpp +++ b/implementations/Crafter.Build-Lint.cpp @@ -2,6 +2,12 @@ // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; +#include +#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) +#include +#else +#include +#endif export module Crafter.Build:Lint_impl; import std; import :Lint; @@ -12,72 +18,242 @@ namespace fs = std::filesystem; using namespace Crafter; namespace { - // Blank //-comments, /*...*/ comments and string/char literal bodies to - // spaces while copying '\n' through, so byte offsets and line numbers in - // the result match the original text. Raw string literals are not - // recognized (documented v1 limitation) — their bodies pass through as - // ordinary string content until the first '"'. - std::string StripComments(std::string_view src) { - enum class State { Code, LineComment, BlockComment, String, Char }; - std::string out; - out.reserve(src.size()); - State state = State::Code; - for (std::size_t i = 0; i < src.size(); ++i) { - char c = src[i]; - char next = i + 1 < src.size() ? src[i + 1] : '\0'; - switch (state) { - case State::Code: - if (c == '/' && next == '/') { - state = State::LineComment; - out += " "; - ++i; - } else if (c == '/' && next == '*') { - state = State::BlockComment; - out += " "; - ++i; - } else if (c == '"') { - state = State::String; - out += c; // keep the delimiter so quoting stays visible - } else if (c == '\'') { - state = State::Char; - out += c; - } else { - out += c; - } - break; - case State::LineComment: - if (c == '\n') { - state = State::Code; - out += c; - } else { - out += ' '; - } - break; - case State::BlockComment: - if (c == '*' && next == '/') { - state = State::Code; - out += " "; - ++i; - } else { - out += c == '\n' ? '\n' : ' '; - } - break; - case State::String: - case State::Char: { - char delim = state == State::String ? '"' : '\''; - if (c == '\\' && next != '\0') { - out += " "; - ++i; - if (next == '\n') out.back() = '\n'; - } else if (c == delim) { - state = State::Code; - out += c; - } else { - out += c == '\n' ? '\n' : ' '; - } - break; - } + // ---------------- libclang ---------------- + // + // libclang is loaded at runtime rather than linked. Linking -lclang would + // break the mingw and MSVC cross-builds at link time and would put a + // libclang.so.NN runtime dependency into the otherwise self-contained + // release tarballs; the clang-c header is used for its declarations only, + // and every call goes through a pointer resolved here. Failure to load is + // a hard error surfaced once by RunLint — there is deliberately no second, + // weaker lexer to fall back to, because two engines disagreeing about what + // is a comment is a worse failure than not running. +#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) + using LibHandle = HMODULE; + LibHandle OpenLibrary(const std::string& name) { return LoadLibraryA(name.c_str()); } + void* LibrarySymbol(LibHandle handle, const std::string& name) { return reinterpret_cast(GetProcAddress(handle, name.c_str())); } + constexpr std::string_view LibClangNames[] = {"libclang.dll", "clang.dll"}; +#else + using LibHandle = void*; + LibHandle OpenLibrary(const std::string& name) { return dlopen(name.c_str(), RTLD_NOW | RTLD_LOCAL); } + void* LibrarySymbol(LibHandle handle, const std::string& name) { return dlsym(handle, name.c_str()); } + constexpr std::string_view LibClangNames[] = { + "libclang.so", "libclang.so.22.1", "libclang.so.21.1", "libclang.so.20.1", + "libclang.so.1", "libclang.dylib", + }; +#endif + + // Signatures come from decltype on the header's declarations, so they can + // never drift from the real API. decltype is unevaluated, so naming the + // functions here does not create a link-time reference to them. + struct LibClang { + LibHandle handle = nullptr; + std::string error; // non-empty exactly when handle is null + + decltype(&clang_createIndex) CreateIndex = nullptr; + decltype(&clang_disposeIndex) DisposeIndex = nullptr; + decltype(&clang_parseTranslationUnit) ParseTranslationUnit = nullptr; + decltype(&clang_disposeTranslationUnit) DisposeTranslationUnit = nullptr; + decltype(&clang_getFile) GetFile = nullptr; + decltype(&clang_getLocationForOffset) GetLocationForOffset = nullptr; + decltype(&clang_getRange) GetRange = nullptr; + decltype(&clang_getRangeStart) GetRangeStart = nullptr; + decltype(&clang_getRangeEnd) GetRangeEnd = nullptr; + decltype(&clang_getFileLocation) GetFileLocation = nullptr; + decltype(&clang_tokenize) Tokenize = nullptr; + decltype(&clang_disposeTokens) DisposeTokens = nullptr; + decltype(&clang_getTokenKind) GetTokenKind = nullptr; + decltype(&clang_getTokenExtent) GetTokenExtent = nullptr; + }; + + LibClang LoadLibClang() { + LibClang lib; + std::vector tried; + // CRAFTER_BUILD_LIBCLANG pins an exact path, mirroring the LIBCXX_DIR / + // CRAFTER_MINGW_DIR overrides used elsewhere. It is exclusive: pointing + // it at a broken path must fail loudly rather than quietly succeed with + // some other libclang, or the override is useless for diagnosing which + // library is actually in play. + std::vector candidates; + if (const char* pinned = std::getenv("CRAFTER_BUILD_LIBCLANG"); pinned && *pinned) { + candidates.emplace_back(pinned); + } else { + for (std::string_view name : LibClangNames) candidates.emplace_back(name); + } + + auto join = [](const std::vector& parts) { + std::string joined; + for (const std::string& part : parts) { + if (!joined.empty()) joined += ", "; + joined += part; } + return joined; + }; + + for (const std::string& name : candidates) { + lib.handle = OpenLibrary(name); + if (lib.handle) break; + tried.push_back(name); + } + if (!lib.handle) { + lib.error = std::format("could not load libclang (tried {}); install clang, or point CRAFTER_BUILD_LIBCLANG at it", join(tried)); + return lib; + } + + std::vector missing; + auto bind = [&](auto& slot, const std::string& name) { + slot = reinterpret_cast>(LibrarySymbol(lib.handle, name)); + if (!slot) missing.push_back(name); + }; + bind(lib.CreateIndex, "clang_createIndex"); + bind(lib.DisposeIndex, "clang_disposeIndex"); + bind(lib.ParseTranslationUnit, "clang_parseTranslationUnit"); + bind(lib.DisposeTranslationUnit, "clang_disposeTranslationUnit"); + bind(lib.GetFile, "clang_getFile"); + bind(lib.GetLocationForOffset, "clang_getLocationForOffset"); + bind(lib.GetRange, "clang_getRange"); + bind(lib.GetRangeStart, "clang_getRangeStart"); + bind(lib.GetRangeEnd, "clang_getRangeEnd"); + bind(lib.GetFileLocation, "clang_getFileLocation"); + bind(lib.Tokenize, "clang_tokenize"); + bind(lib.DisposeTokens, "clang_disposeTokens"); + bind(lib.GetTokenKind, "clang_getTokenKind"); + bind(lib.GetTokenExtent, "clang_getTokenExtent"); + if (!missing.empty()) { + lib.handle = nullptr; + lib.error = std::format("loaded {} but it is missing {}", candidates.front(), join(missing)); + } + return lib; + } + + const LibClang& Clang() { + static const LibClang Lib = LoadLibClang(); + return Lib; + } + + // The -x language for a source file, or empty when we must not lex it. + // .cppm needs c++-module explicitly: libclang does not infer a module unit + // from the extension and silently treats every flag as a linker input if + // left to guess. Shaders and data files return empty — lexing GLSL as C++ + // yields plausible-looking nonsense. + std::string_view LexLanguage(const fs::path& file) { + std::string ext = file.extension().string(); + if (ext == ".cppm" || ext == ".ixx") return "c++-module"; + if (ext == ".cpp" || ext == ".cc" || ext == ".cxx" || ext == ".h" || ext == ".hpp" || ext == ".cu") return "c++"; + if (ext == ".c") return "c"; + return {}; + } + + LintTokenKind MapTokenKind(CXTokenKind kind) { + switch (kind) { + case CXToken_Punctuation: return LintTokenKind::Punctuation; + case CXToken_Keyword: return LintTokenKind::Keyword; + case CXToken_Identifier: return LintTokenKind::Identifier; + case CXToken_Literal: return LintTokenKind::Literal; + case CXToken_Comment: return LintTokenKind::Comment; + } + return LintTokenKind::Punctuation; + } + + // Lex `content` as if it were `file`, returning tokens in source order. + // + // The buffer is handed over as an unsaved file, so a transform's in-memory + // edits are what get lexed — never the stale bytes on disk. The parse is + // expected to fail (a module unit's `import std;` cannot resolve without + // PCMs, and we deliberately do not supply the build's flags here); that + // does not matter, because clang_tokenize re-lexes the buffer and lexing + // has no semantic prerequisites. SingleFileParse keeps it from chasing + // #includes it does not need. + std::vector LexFile(const fs::path& file, const std::string& content) { + std::string_view language = LexLanguage(file); + if (language.empty()) return {}; + const LibClang& lc = Clang(); + if (!lc.handle) return {}; + + std::string path = file.string(); + std::string languageArg = std::format("-x{}", language); + std::string standardArg = language == "c" ? "-std=c23" : "-std=c++26"; + // clang's argv is char* by contract; keep the raw pointers confined to + // this call rather than letting them into any signature of ours. + std::array args{languageArg.c_str(), standardArg.c_str(), "-ferror-limit=0", "-w"}; + + CXUnsavedFile unsaved{}; + unsaved.Filename = path.c_str(); + unsaved.Contents = content.data(); + unsaved.Length = static_cast(content.size()); + + CXIndex index = lc.CreateIndex(0, 0); + if (!index) return {}; + CXTranslationUnit tu = lc.ParseTranslationUnit(index, path.c_str(), args.data(), static_cast(args.size()), &unsaved, 1, CXTranslationUnit_SingleFileParse | CXTranslationUnit_SkipFunctionBodies | CXTranslationUnit_KeepGoing); + if (!tu) { + lc.DisposeIndex(index); + return {}; + } + + std::vector tokens; + if (CXFile cxFile = lc.GetFile(tu, path.c_str())) { + CXSourceRange whole = lc.GetRange(lc.GetLocationForOffset(tu, cxFile, 0), lc.GetLocationForOffset(tu, cxFile, static_cast(content.size()))); + CXToken* raw = nullptr; + std::uint32_t count = 0; + lc.Tokenize(tu, whole, &raw, &count); + tokens.reserve(count); + for (std::uint32_t i = 0; i < count; ++i) { + CXSourceRange extent = lc.GetTokenExtent(tu, raw[i]); + std::uint32_t line = 0; + std::uint32_t column = 0; + std::uint32_t begin = 0; + std::uint32_t end = 0; + lc.GetFileLocation(lc.GetRangeStart(extent), nullptr, &line, &column, &begin); + lc.GetFileLocation(lc.GetRangeEnd(extent), nullptr, nullptr, nullptr, &end); + if (end < begin || begin > content.size()) continue; + tokens.push_back({MapTokenKind(lc.GetTokenKind(raw[i])), begin, std::min(end - begin, content.size() - begin), line, column}); + } + if (raw) lc.DisposeTokens(tu, raw, count); + } + lc.DisposeTranslationUnit(tu); + lc.DisposeIndex(index); + return tokens; + } + + // Blank comments and the bodies of string/character literals to spaces, + // copying '\n' through so byte offsets and line numbers in the result + // match the original text exactly. + // + // Derived from the token stream rather than scanned character by + // character, which is what makes raw strings, escapes, encoding prefixes + // and a literal like '"' come out right. Editing a copy of the buffer in + // place — rather than appending to a fresh string — makes the + // length-preserving property structural instead of something every branch + // has to remember. + std::string StripLiterals(const std::string& content, std::span tokens) { + std::string out = content; + auto blank = [&out](std::size_t from, std::size_t to) { + for (std::size_t i = from; i < to && i < out.size(); ++i) { + if (out[i] != '\n') out[i] = ' '; + } + }; + for (const LintToken& token : tokens) { + std::size_t begin = token.offset; + std::size_t end = token.offset + token.length; + if (token.kind == LintTokenKind::Comment) { + blank(begin, end); + continue; + } + if (token.kind != LintTokenKind::Literal || token.length < 2) continue; + std::string_view text(content.data() + begin, token.length); + // Numeric literals are code and stay; only string and character + // literals have a body to hide. A digit separator makes 1'000 look + // quote-ish, so require everything before the quote to be an + // encoding prefix (L, u, U, u8, R and their combinations). + std::size_t quote = text.find_first_of("\"'"); + if (quote == std::string_view::npos) continue; + std::string_view prefix = text.substr(0, quote); + if (!std::ranges::all_of(prefix, [](char c) { return c == 'L' || c == 'u' || c == 'U' || c == '8' || c == 'R'; })) continue; + // Keep the opening quote and the closing one, blank everything + // between. For a raw string that also blanks the R"delim( and + // )delim" scaffolding, leaving exactly two quotes — which is what + // rules counting quotes to find a literal's extent rely on. + blank(begin + quote + 1, end - 1); } return out; } @@ -159,11 +335,50 @@ std::string_view LintContext::Line(std::size_t n) const { const std::string& LintContext::CommentStripped() { if (!commentStrippedCache) { - commentStrippedCache = StripComments(content); + commentStrippedCache = StripLiterals(content, Tokens()); } return *commentStrippedCache; } +std::span LintContext::Tokens() { + if (!tokenCache) tokenCache = LexFile(file, content); + return *tokenCache; +} + +std::string_view LintContext::TokenText(const LintToken& token) const { + if (token.offset >= content.size()) return {}; + return std::string_view(content).substr(token.offset, token.length); +} + +std::span LintContext::TokensOnLine(std::size_t line) { + // Tokens come back in source order, so one line's tokens are a contiguous + // run and can be bracketed by binary search. + std::span all = Tokens(); + auto begin = std::ranges::lower_bound(all, line, {}, &LintToken::line); + auto end = std::ranges::upper_bound(all, line, {}, &LintToken::line); + return all.subspan(static_cast(begin - all.begin()), static_cast(end - begin)); +} + +bool LintContext::LineHasComment(std::size_t line) { + std::span onLine = TokensOnLine(line); + return std::ranges::any_of(onLine, [](const LintToken& t) { return t.kind == LintTokenKind::Comment; }); +} + +bool LintContext::LineHasMultiLineToken(std::size_t line) { + if (!spannedLineCache) { + std::vector spanned(lines.size(), false); + for (const LintToken& token : Tokens()) { + std::size_t crossed = static_cast(std::ranges::count(TokenText(token), '\n')); + if (crossed == 0) continue; + for (std::size_t n = token.line; n <= token.line + crossed && n <= spanned.size(); ++n) { + spanned[n - 1] = true; + } + } + spannedLineCache = std::move(spanned); + } + return line >= 1 && line <= spannedLineCache->size() && (*spannedLineCache)[line - 1]; +} + void LintContext::Report(std::size_t line, std::string message) { sink->push_back({file, line, activeRule, std::move(message)}); } @@ -226,6 +441,8 @@ void LintContext::SetContent(std::string newContent) { lines = SplitLines(content); commentStrippedCache.reset(); suppressionsCache.reset(); // line numbers may have shifted — re-parse + tokenCache.reset(); // offsets refer to the old buffer — re-lex + spannedLineCache.reset(); // derived from tokenCache } void Configuration::AddLintRule(std::string name, std::function check) { @@ -235,6 +452,15 @@ void Configuration::AddLintRule(std::string name, std::function> lineRules; }; + // Lexical class of a LintToken, mirroring clang's token kinds one-to-one. + enum class LintTokenKind { + Punctuation, + Keyword, + Identifier, + Literal, // string, raw string, character, integer, floating literal + Comment, // // ... or /* ... */ + }; + + // One token from LintContext::Tokens(). `offset`/`length` are byte offsets + // into LintContext::content and stay valid until the next SetContent. + // + // A raw string literal or a block comment is ONE token and may span lines, + // which is exactly what the hand-rolled scanners could not represent. The + // stream also covers text inside preprocessor branches that are inactive + // for the host — clang_tokenize lexes, it does not evaluate #if — so token + // rules see every platform's code, not just the one being built. + struct LintToken { + LintTokenKind kind = LintTokenKind::Punctuation; + std::size_t offset = 0; + std::size_t length = 0; + std::size_t line = 0; // 1-based, of the token's first byte + std::size_t column = 0; // 1-based, of the token's first byte + }; + // Per-file view handed to each LintRule's check callback. Every member // function is out-of-line and CRAFTER_API (defined in Crafter.Build:Lint's // implementation unit) because rule lambdas execute from the user's @@ -136,10 +161,16 @@ export namespace Crafter { CRAFTER_API std::string Extension() const; // ".cppm", ".cpp", ".h", ... CRAFTER_API std::string_view Line(std::size_t n) const; // 1-based; empty if out of range - // `content` with //-comments, /*...*/ comments and string/char literal - // bodies blanked to spaces, newlines preserved — offsets and line - // numbers stay valid. Built on first call, cached per file. Raw string - // literals are not recognized (v1 limitation). + // `content` with comments and string/char literal bodies blanked to + // spaces, newlines preserved — offsets and line numbers stay valid. + // Built from Tokens() on first call, cached per file, so raw strings, + // escapes, encoding prefixes and literals like '"' all come out right. + // A raw string is reduced to R"…" with the body and the delimiter + // scaffolding blanked, leaving exactly two quote characters. + // + // Convenient for a quick scan, but Tokens() is the better tool for + // anything structural: this view cannot tell an identifier from a + // keyword, and it has already thrown away where the literals were. CRAFTER_API const std::string& CommentStripped(); // Record a finding at `line` (1-based; pass 0 for a whole-file finding). CRAFTER_API void Report(std::size_t line, std::string message); @@ -165,11 +196,42 @@ export namespace Crafter { // re-parsed after SetContent. CRAFTER_API bool Suppressed(std::string_view rule, std::size_t line); + // The file lexed by clang, in source order. Built on first call, cached + // per file, re-lexed after SetContent. Empty for extensions that are + // not C or C++ (shaders): lexing GLSL as C++ would produce nonsense. + // + // Prefer this to scanning characters. It is the only view that gets + // raw strings, line splices, digraphs and nested quoting right, and + // offsets index straight into `content`, so a transform can find in + // the token stream and edit in place. + CRAFTER_API std::span Tokens(); + // The token's own bytes: content.substr(tok.offset, tok.length). + CRAFTER_API std::string_view TokenText(const LintToken& token) const; + // Tokens whose FIRST byte is on `line` (1-based). A token that starts + // earlier and spans into `line` — a raw string, a block comment — is + // not included; ask Tokens() directly when that matters. + CRAFTER_API std::span TokensOnLine(std::size_t line); + // True when a comment token starts on `line`. Replaces `Line(n).contains("//")`, + // which false-positives on a `//` inside a string literal. + CRAFTER_API bool LineHasComment(std::size_t line); + // True when `line` (1-based) is touched by a token that spans more + // than one line — a raw string or a block comment. A transform that + // joins, splits or rewrites such a line changes what is inside that + // token, so this is the guard to consult before reflowing anything. + // Replaces `Line(n).contains("R\"")`, which both misses raw strings + // opened on an earlier line and fires on the characters R" appearing + // inside an ordinary literal. + CRAFTER_API bool LineHasMultiLineToken(std::size_t line); + // Driver wiring — set by RunLint before each check call. Not for rules. std::string activeRule; std::vector* sink = nullptr; std::optional commentStrippedCache; std::optional suppressionsCache; + std::optional> tokenCache; + // Per-line flag, 0-based, for LineHasMultiLineToken. Derived from + // tokenCache and invalidated with it. + std::optional> spannedLineCache; }; // A named lint rule: `check` runs once per (rule, file) over the project's diff --git a/interfaces/Crafter.Build-Lint.cppm b/interfaces/Crafter.Build-Lint.cppm index d6c2a8a..7d45b3f 100644 --- a/interfaces/Crafter.Build-Lint.cppm +++ b/interfaces/Crafter.Build-Lint.cppm @@ -39,10 +39,13 @@ export namespace Crafter { std::vector changedFiles; std::size_t filesLinted = 0; std::size_t rulesRun = 0; // rules remaining after glob filter - std::size_t errors = 0; // rule exceptions + write failures + // Rule exceptions, write failures, and infrastructure failures such as + // libclang not loading. Counted in Clean() so a run that could not do + // its job never looks like a run that found nothing. + std::size_t errors = 0; bool noRulesDefined = false; // project registered no rules at all // Host-side only (like TestSummary::AllPassed), safe as in-class inline. - bool Clean() const { return findings.empty() && !noRulesDefined; } + bool Clean() const { return findings.empty() && !noRulesDefined && errors == 0; } }; // Run the project's lint rules over its own sources: module interfaces diff --git a/lint-rules.h b/lint-rules.h index 12461fd..3cf8c92 100644 --- a/lint-rules.h +++ b/lint-rules.h @@ -408,9 +408,12 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) { lineStart += line.size() + 1; std::string_view trimmed = Trim(line); if (trimmed.starts_with('#')) continue; - // Raw strings defeat the comment stripper's quote tracking; any - // literal-+ pattern on such a line is a manual fix. - bool hasRaw = std::string_view(ctx.content).substr(lineOff, line.size()).contains("R\""); + // A chain is read within one line, so a literal that spans lines + // would be sliced in half by chainEnd. Single-line raw strings are + // fine: they reduce to R"…" in the stripped view, so they fail the + // "is a plain literal" test below and travel through as an + // argument, spelling and all. + bool spansLines = ctx.LineHasMultiLineToken(li + 1); std::size_t searchFrom = 0; while (searchFrom < line.size()) { @@ -427,8 +430,8 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) { bool literalAdjacent = (leftEnd > 0 && line[leftEnd - 1] == '"') || (rightBegin < line.size() && line[rightBegin] == '"'); if (!literalAdjacent) continue; - if (hasRaw) { - ctx.Report(li + 1, "use std::format instead of string concatenation with + (raw-string line, fix manually)"); + if (spansLines) { + ctx.Report(li + 1, "use std::format instead of string concatenation with + (multi-line literal, fix manually)"); break; } @@ -607,7 +610,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) { std::string lineStr(stripped[i]); std::smatch m; std::string_view raw = i + 1 <= ctx.lines.size() ? ctx.Line(i + 1) : std::string_view{}; - if (!raw.contains("//") && !ctx.Suppressed("single-declaration", i + 1) + if (!ctx.LineHasComment(i + 1) && !ctx.Suppressed("single-declaration", i + 1) && std::regex_match(lineStr, m, simpleMulti)) { std::string indent = m[1].str(), type = m[2].str(), decls = m[3].str(); std::size_t start = 0; @@ -644,7 +647,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) { if (trimmed == "{" && !outLines.empty()) { std::string_view prevTrim = i > 0 ? Trim(stripped[i - 1]) : std::string_view{}; bool headerBefore = prevTrim.ends_with(')') || prevTrim == "else" || prevTrim == "do" || prevTrim == "try"; - bool hasComment = ctx.Line(i + 1).contains("//") || (i > 0 && ctx.Line(i).contains("//")); + bool hasComment = ctx.LineHasComment(i + 1) || (i > 0 && ctx.LineHasComment(i)); bool suppressed = ctx.Suppressed("brace-style", i) || ctx.Suppressed("brace-style", i + 1); if (headerBefore && !hasComment && !suppressed) { std::string& prev = outLines.back(); @@ -680,7 +683,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) { if (i + 1 < ctx.lines.size() && std::regex_match(lineStr, ifHeader) && ParenDelta(stripped[i]) == 0) { std::string_view body = Trim(stripped[i + 1]); bool joinable = !body.empty() && body != "{" && !body.starts_with("if") && body.ends_with(';') - && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 2).contains("//") + && !ctx.LineHasComment(i + 1) && !ctx.LineHasComment(i + 2) && !ctx.Suppressed("if-single-line", i + 1) && !ctx.Suppressed("if-single-line", i + 2); std::string joined = std::string(ctx.Line(i + 1)); while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back(); @@ -724,7 +727,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) { std::string_view trimmed = Trim(stripped[i]); std::int64_t delta = ParenDelta(stripped[i]); bool candidate = delta > 0 && !trimmed.empty() && !trimmed.starts_with('#') - && !trimmed.ends_with('{') && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\""); + && !trimmed.ends_with('{') && !ctx.LineHasComment(i + 1) && !ctx.LineHasMultiLineToken(i + 1); if (candidate) { std::string joined(ctx.Line(i + 1)); std::size_t j = i + 1; @@ -736,7 +739,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) { // lambda body starts — leave those wrapped. bool closes = delta + ParenDelta(stripped[j]) <= 0; if (next.empty() || (next.ends_with('{') && !closes) - || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) { + || ctx.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) { ok = false; break; } @@ -757,7 +760,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) { continue; } } else if (delta == 0 && i + 1 < ctx.lines.size() && !trimmed.starts_with('#') - && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"")) { + && !ctx.LineHasComment(i + 1) && !ctx.LineHasMultiLineToken(i + 1)) { auto isOpStart = [](std::string_view s) { return s.starts_with("&&") || s.starts_with("||") || s.starts_with("| "); }; @@ -771,7 +774,7 @@ inline void AddProjectLintRules(Crafter::Configuration& cfg) { std::size_t j = i + 1; bool ok = true; while (j < ctx.lines.size() && isOpStart(Trim(stripped[j]))) { - if (ParenDelta(stripped[j]) != 0 || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) { + if (ParenDelta(stripped[j]) != 0 || ctx.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) { ok = false; break; } diff --git a/tests/HouseRules/main.cpp b/tests/HouseRules/main.cpp index 0b3593e..7afa0a5 100644 --- a/tests/HouseRules/main.cpp +++ b/tests/HouseRules/main.cpp @@ -252,6 +252,49 @@ int main() { Check(second.summary.changedFiles.empty(), "wrap-join is idempotent after the fixpoint"); } + // The guards used to be substring probes over the raw line, so a literal + // whose TEXT contained `//` or `R"` looked like a comment or a raw string + // and silently disabled the rule. Both shapes occur in this repo's own + // sources — "MARKER" ends in the characters R", and any string mentioning + // a lint-disable directive contains //. + { + RuleRun r = RunRule("void F() {\n" " auto hit = text.find(\"MARKER\",\n" " start);\n" "}\n", "wrap-join", LintMode::Apply); + Check(r.text.contains("text.find(\"MARKER\", start);"), "R\" inside a literal no longer blocks wrap-join"); + } + { + RuleRun r = RunRule("void F() {\n" " Check(read() == \"// lint-disable-next-line trim\\n\",\n" " \"message\");\n" "}\n", "wrap-join", LintMode::Apply); + Check(r.text.contains("\\n\", \"message\");"), "// inside a literal no longer blocks wrap-join"); + } + + // A real trailing comment still blocks the join: text pulled up past a + // `//` would be swallowed by it. + { + RuleRun r = RunRule("void F() {\n" " auto v = g(alpha, // why\n" " beta);\n" "}\n", "wrap-join", LintMode::Apply); + Check(r.text.contains("g(alpha, // why\n"), "a real comment still blocks wrap-join"); + } + + // A genuinely multi-line literal is never reflowed — joining its lines + // would change the string's contents. + { + std::string_view source = "void F() {\n" + " auto text = R\"sql(SELECT a,\n" + " b FROM t)sql\";\n" + "}\n"; + RuleRun r = RunRule(source, "wrap-join", LintMode::Apply); + Check(r.text == source, "wrap-join leaves a multi-line raw string alone"); + RuleRun paren = RunRule(source, "paren-spacing", LintMode::Apply); + Check(paren.text == source, "paren-spacing leaves a multi-line raw string alone"); + } + + // Type keywords inside a literal are text, not declarations. + { + std::string_view source = "void F() {\n" + " auto sql = R\"q(int x; unsigned long y;)q\";\n" + "}\n"; + RuleRun r = RunRule(source, "fixed-width-types", LintMode::Apply); + Check(r.text == source, "fixed-width-types leaves type names inside a raw string alone"); + } + if (Failures > 0) { std::println(std::cerr, "{} assertions failed", Failures); return 1; diff --git a/tests/Lint/main.cpp b/tests/Lint/main.cpp index c900b27..fa81ec4 100644 --- a/tests/Lint/main.cpp +++ b/tests/Lint/main.cpp @@ -156,8 +156,7 @@ int main() { Configuration cfg = FixtureConfig(); cfg.AddLintRule("marker", [](LintContext& ctx) { const std::string& code = ctx.CommentStripped(); - for (std::size_t pos = code.find("MARKER"); pos != std::string::npos; - pos = code.find("MARKER", pos + 1)) { + for (std::size_t pos = code.find("MARKER"); pos != std::string::npos; pos = code.find("MARKER", pos + 1)) { std::size_t line = 1 + std::count(code.begin(), code.begin() + pos, '\n'); ctx.Report(line, "MARKER in code"); } @@ -370,8 +369,7 @@ int main() { Configuration cfg = s.Config({"a"}); AddTrimRule(cfg); RunLint(cfg, Mode(LintMode::Apply)); - Check(s.Read("a") == "// lint-disable-next-line trim\nkeep \ntrim\n", - "suppressed line keeps its bytes; the unsuppressed one is fixed"); + Check(s.Read("a") == "// lint-disable-next-line trim\nkeep \ntrim\n", "suppressed line keeps its bytes; the unsuppressed one is fixed"); } // Multiple rule names on one directive (space- or comma-separated). @@ -427,6 +425,136 @@ int main() { Check(s.Read("a") == "// lint-disable-file flag\nbad\n", "other rules still format"); } + // ---------------- token layer ---------------- + // + // The source is spelled with escaped literals rather than a raw string so + // that this file stays lintable by the very rules under test; the scratch + // file it writes does contain a genuine multi-line raw string. + { + constexpr std::string_view Source = + "#ifdef CRAFTER_LINT_NEVER_DEFINED\n" // 1 + "void HiddenBranch();\n" // 2 + "#endif\n" // 3 + "// a real comment\n" // 4 + "int url = 1; // https://example.com\n" // 5 + "auto raw = R\"raw(spans lines\n" // 6 + " // not a comment\n" // 7 + " int notADecl;\n" // 8 + ")raw\";\n"; // 9 + + Scratch s("tokens"); + s.Write("f", Source); + Configuration cfg = s.Config({"f"}); + cfg.AddLintRule("tokens", [](LintContext& ctx) { + std::span toks = ctx.Tokens(); + Check(!toks.empty(), "tokens: file lexes to a non-empty stream"); + + // Every token's offset/length must address its own bytes, or a + // transform editing at an offset would corrupt the file. + bool offsetsSound = true; + for (const LintToken& t : toks) { + if (t.offset + t.length > ctx.content.size() || ctx.TokenText(t).empty()) offsetsSound = false; + } + Check(offsetsSound, "tokens: every offset/length addresses real bytes"); + + // Ordered by offset, so binary search in TokensOnLine is valid. + bool ordered = std::ranges::is_sorted(toks, {}, &LintToken::offset); + Check(ordered, "tokens: stream is in source order"); + + // Inactive #ifdef branch is still lexed — this is what keeps token + // rules covering every platform, unlike an AST. + bool sawHidden = std::ranges::any_of(toks, [&](const LintToken& t) { + return t.kind == LintTokenKind::Identifier && ctx.TokenText(t) == "HiddenBranch"; + }); + Check(sawHidden, "tokens: inactive #ifdef branch is lexed"); + + // A multi-line raw string is exactly one Literal, comment markers + // and declarations inside it included. + auto isRaw = [&](const LintToken& t) { return ctx.TokenText(t).starts_with("R\"raw("); }; + Check(std::ranges::count_if(toks, isRaw) == 1, "tokens: raw string is a single token"); + auto raw = std::ranges::find_if(toks, isRaw); + if (raw != toks.end()) { + Check(raw->kind == LintTokenKind::Literal, "tokens: raw string is a Literal"); + Check(raw->line == 6, "tokens: raw string starts on line 6"); + Check(ctx.TokenText(*raw).contains("// not a comment"), "tokens: raw string body kept intact"); + Check(ctx.TokenText(*raw).ends_with(")raw\""), "tokens: raw string spans to its own terminator"); + } + + // The only comments are the two real ones on lines 4 and 5 — the + // `//` on line 7 lives inside the raw string. + std::vector commentLines; + for (const LintToken& t : toks) { + if (t.kind == LintTokenKind::Comment) commentLines.push_back(t.line); + } + Check(commentLines == std::vector{4, 5}, "tokens: only real comments are Comment tokens"); + Check(ctx.LineHasComment(4), "tokens: LineHasComment finds a whole-line comment"); + Check(ctx.LineHasComment(5), "tokens: LineHasComment finds a trailing comment"); + Check(!ctx.LineHasComment(7), "tokens: `//` inside a raw string is not a comment"); + Check(!ctx.LineHasComment(2), "tokens: code-only line has no comment"); + + // TokensOnLine brackets by starting line. + std::span line2 = ctx.TokensOnLine(2); + Check(!line2.empty() && ctx.TokenText(line2.front()) == "void", "tokens: TokensOnLine starts at the line's first token"); + Check(std::ranges::all_of(line2, [](const LintToken& t) { return t.line == 2; }), "tokens: TokensOnLine stays on its line"); + + // SetContent must invalidate the cache, or offsets point into a + // buffer that no longer exists. + ctx.SetContent("int replaced;\n"); + std::span after = ctx.Tokens(); + Check(!after.empty() && ctx.TokenText(after.front()) == "int", "tokens: re-lexed after SetContent"); + Check(std::ranges::none_of(after, [&](const LintToken& t) { return ctx.TokenText(t) == "HiddenBranch"; }), "tokens: stale tokens are dropped after SetContent"); + }); + RunLint(cfg, Mode(LintMode::Report)); + } + + // CommentStripped over a raw string holding an ODD number of quotes. The + // character-scanning version treated R"( as an ordinary string open, so the + // quote inside the body closed it early and every following line was + // swallowed as literal text — code after the raw string vanished from the + // stripped view. Lexing gets the extent right. + { + constexpr std::string_view Source = + "auto banner = R\"(he said \"hi)\";\n" // 1: one quote inside the body + "int afterRaw = 2;\n" // 2: must survive as code + "// MARKER comment\n" // 3 + "auto plain = \"MARKER text\";\n"; // 4 + + Scratch s("strip-rawstring"); + s.Write("f", Source); + Configuration cfg = s.Config({"f"}); + cfg.AddLintRule("strip", [](LintContext& ctx) { + const std::string& code = ctx.CommentStripped(); + Check(code.size() == ctx.content.size(), "strip: byte length preserved"); + Check(std::ranges::count(code, '\n') == std::ranges::count(ctx.content, '\n'), "strip: newlines preserved"); + Check(code.contains("afterRaw"), "strip: code after an odd-quoted raw string survives"); + Check(!code.contains("he said"), "strip: raw string body is blanked"); + Check(!code.contains("MARKER"), "strip: comment and literal bodies are blanked"); + Check(code.contains("auto plain ="), "strip: code around a literal survives"); + // The raw string collapses to R"…" — exactly two quotes, so rules + // that bracket a literal by counting quotes still work. + std::string_view line1 = std::string_view(code).substr(0, code.find('\n')); + Check(std::ranges::count(line1, '"') == 2, "strip: raw string leaves exactly two quotes"); + }); + RunLint(cfg, Mode(LintMode::Report)); + } + + // Non-C++ extensions are not lexed: GLSL through a C++ lexer would produce + // plausible-looking nonsense rather than an honest refusal. + { + Scratch s("tokens-foreign"); + fs::path shader = s.dir / "f.frag"; + { + std::ofstream f(shader, std::ios::binary | std::ios::trunc); + f << "#version 450\nvoid main() { }\n"; + } + Configuration cfg = s.Config({}); + cfg.shaders.emplace_back(fs::path(shader), "main", ShaderType::Fragment); + cfg.AddLintRule("no-lex", [](LintContext& ctx) { + Check(ctx.Tokens().empty(), "tokens: shaders are not lexed as C++"); + }); + RunLint(cfg, Mode(LintMode::Report)); + } + if (Failures > 0) { std::println(std::cerr, "{} assertions failed", Failures); return 1;