From 9e734b71b2cd5218afa39f0f40650cc991ff8af4 Mon Sep 17 00:00:00 2001 From: "Andrei.Ovcharenko" Date: Sat, 11 Jul 2026 08:30:21 +0300 Subject: [PATCH 1/4] Harden core parsing, coverage, and lifecycle --- .github/workflows/CI_build.yml | 17 ++ .github/workflows/CI_update_remote.yml | 52 ++-- .github/workflows/actionlint.yml | 7 +- .github/workflows/codeql.yml | 5 + .github/workflows/dependency-review.yml | 8 +- .github/workflows/release.yml | 8 + .github/workflows/scorecard.yml | 3 +- Package.proj | 26 +- scripts/Invoke-NppCompatibilitySmoke.ps1 | 42 +++- scripts/Test-NppCompatibilitySmokeSafety.ps1 | 40 +++ src/MarkdownTableCore.cpp | 231 ++++++++++++++---- src/MarkdownTableEditorPlugin.cpp | 31 +-- src/PluginDefinition.cpp | 102 +++----- test-fixtures/markdown-table-core-golden.json | 90 +++++++ tests/CodeCoverage.config | 16 ++ tests/CorePerformance.cpp | 11 + tests/CoreSmoke.cpp | 58 +++++ tests/CoreSmoke.vcxproj | 6 + tests/GoldenFixtureTests.cpp | 18 +- 19 files changed, 605 insertions(+), 166 deletions(-) create mode 100644 scripts/Test-NppCompatibilitySmokeSafety.ps1 create mode 100644 tests/CodeCoverage.config diff --git a/.github/workflows/CI_build.yml b/.github/workflows/CI_build.yml index 958a192..197b9bd 100644 --- a/.github/workflows/CI_build.yml +++ b/.github/workflows/CI_build.yml @@ -5,12 +5,18 @@ on: [push, pull_request] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: name: Build and tests (${{ matrix.build_configuration }}, ${{ matrix.build_platform }}) runs-on: windows-2022 + timeout-minutes: 30 strategy: + fail-fast: false max-parallel: 6 matrix: build_configuration: [Release, Debug] @@ -42,6 +48,11 @@ jobs: if: matrix.build_platform == 'x64' && matrix.build_configuration == 'Release' run: msbuild Package.proj /m /t:CorePerformance /p:Configuration=Release /p:Platform=x64 + - name: Compatibility script safety tests + if: matrix.build_platform == 'x64' && matrix.build_configuration == 'Debug' + shell: pwsh + run: msbuild Package.proj /m /t:RunCompatibilityScriptSafetyTests + - name: Upload coverage to Codecov if: matrix.build_platform == 'x64' && matrix.build_configuration == 'Debug' uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 @@ -58,6 +69,8 @@ jobs: with: name: plugin_dll_x64 path: bin64\MarkdownTableEditor.dll + if-no-files-found: error + retention-days: 14 - name: Archive artifacts for Win32 if: matrix.build_platform == 'Win32' && matrix.build_configuration == 'Release' @@ -65,6 +78,8 @@ jobs: with: name: plugin_dll_x86 path: bin\MarkdownTableEditor.dll + if-no-files-found: error + retention-days: 14 - name: Archive artifacts for ARM64 if: matrix.build_platform == 'ARM64' && matrix.build_configuration == 'Release' @@ -72,6 +87,8 @@ jobs: with: name: plugin_dll_arm64 path: arm64\MarkdownTableEditor.dll + if-no-files-found: error + retention-days: 14 npp-ui-smoke: name: Tests / Notepad++ compatibility smoke diff --git a/.github/workflows/CI_update_remote.yml b/.github/workflows/CI_update_remote.yml index 5bf2c04..165ca71 100644 --- a/.github/workflows/CI_update_remote.yml +++ b/.github/workflows/CI_update_remote.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +concurrency: + group: notepad-interface-sync + cancel-in-progress: false + jobs: update: # Keep the upstream sync limited to the canonical project repository. @@ -20,10 +24,13 @@ jobs: contents: write runs-on: windows-2022 + timeout-minutes: 30 steps: - name: Checkout Markdown Table Editor repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 - name: Checkout N++ repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -39,10 +46,6 @@ jobs: run: | any_difference=false - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - any_difference=true - fi - pairs=( "src/Scintilla.h|npp_repo/scintilla/include/Scintilla.h" "src/Sci_Position.h|npp_repo/scintilla/include/Sci_Position.h" @@ -66,37 +69,44 @@ jobs: fi done - if [ "$any_difference" = true ]; then - echo "ANY_DIFFERENCE=True" >> "$GITHUB_OUTPUT" - fi + git diff --check + echo "ANY_DIFFERENCE=$any_difference" >> "$GITHUB_OUTPUT" date_now="$(date +%Y_%B_%d)" echo "CHANGE DATE will be $date_now" echo "CHANGE_DATE=$date_now" >> "$GITHUB_OUTPUT" - echo "HEAD_REF=$(git --git-dir=npp_repo/.git describe --always '@')" >> "$GITHUB_OUTPUT" + echo "HEAD_REF=$(git -C npp_repo rev-parse HEAD)" >> "$GITHUB_OUTPUT" rm -rf npp_repo git status - - name: Commit any updates to the repo + - name: If ANY_DIFFERENCE then add MSBuild to PATH + if: steps.diff_step.outputs.ANY_DIFFERENCE == 'true' + uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3 + + - name: If ANY_DIFFERENCE then ensure ARM64 C++ build tools + if: steps.diff_step.outputs.ANY_DIFFERENCE == 'true' + shell: pwsh + run: .\scripts\Ensure-VsArm64Tools.ps1 + + - name: If ANY_DIFFERENCE then validate the synchronized interfaces + if: steps.diff_step.outputs.ANY_DIFFERENCE == 'true' + shell: pwsh + run: msbuild Package.proj /m "/t:BuildReleaseAllPlatforms;RunCoreSmokeTests;RunPluginShortcutSmokeTests;RunCompatibilityScriptSafetyTests" + + - name: Commit validated updates to the repo + if: steps.diff_step.outputs.ANY_DIFFERENCE == 'true' + id: auto_commit uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7 with: # GitHub will auto-link the upstream ref to the corresponding commit commit_message: Sync Notepad++ interface files with notepad-plus-plus/notepad-plus-plus@${{ steps.diff_step.outputs.HEAD_REF }} - - name: If ANY_DIFFERENCE then add MSBuild to PATH - if: ${{steps.diff_step.outputs.ANY_DIFFERENCE}} - uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3 - - - name: If ANY_DIFFERENCE then run MSBuild of plugin dll - if: ${{steps.diff_step.outputs.ANY_DIFFERENCE}} - run: msbuild Package.proj /m /t:BuildReleaseAllPlatforms - - name: Release - if: ${{steps.diff_step.outputs.ANY_DIFFERENCE}} + if: steps.auto_commit.outputs.changes_detected == 'true' uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 with: - name: Markdown Table Editor Notepad++ interface sync v${{ steps.diff_step.outputs.CHANGE_DATE }}.${{github.run_number}}.${{github.run_attempt}} - tag_name: v${{ steps.diff_step.outputs.CHANGE_DATE }}.${{github.run_number}}.${{github.run_attempt}} - target_commitish: ${{ github.ref_name }} + name: Markdown Table Editor Notepad++ interface sync ${{ steps.diff_step.outputs.CHANGE_DATE }}.${{github.run_number}}.${{github.run_attempt}} + tag_name: npp-interface-sync-${{ steps.diff_step.outputs.CHANGE_DATE }}.${{github.run_number}}.${{github.run_attempt}} + target_commitish: ${{ steps.auto_commit.outputs.commit_hash }} token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 18a78e3..e9fff2c 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -12,9 +12,14 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: actionlint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 144201c..770700a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,10 +12,15 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: analyze: name: Analyze (cpp) runs-on: windows-2022 + timeout-minutes: 30 permissions: actions: read contents: read diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 3efed37..11d74e8 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,15 +2,19 @@ name: Dependency Review on: pull_request: - workflow_dispatch: permissions: contents: read pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: dependency-review: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2949cf..cd82680 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,9 +13,14 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: version: runs-on: windows-2022 + timeout-minutes: 10 outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} @@ -47,6 +52,7 @@ jobs: build: needs: version runs-on: windows-2022 + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -87,6 +93,7 @@ jobs: build/MarkdownTableEditor-${{ needs.version.outputs.version }}-${{ matrix.label }}.zip build/MarkdownTableEditor-${{ needs.version.outputs.version }}-${{ matrix.label }}-pluginadmin.zip if-no-files-found: error + retention-days: 7 compatibility-smoke: needs: version @@ -111,6 +118,7 @@ jobs: - build - compatibility-smoke runs-on: windows-2022 + timeout-minutes: 15 permissions: contents: write id-token: write diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 753cad3..3818537 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -14,7 +14,7 @@ permissions: read-all jobs: scorecard: name: OSSF Scorecards - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: actions: read @@ -45,3 +45,4 @@ jobs: name: openssf-scorecard path: scorecard-results.sarif if-no-files-found: error + retention-days: 14 diff --git a/Package.proj b/Package.proj index 0b4ade4..f347547 100644 --- a/Package.proj +++ b/Package.proj @@ -194,7 +194,7 @@ Func formatDecimal = value => value.ToString("0.###############", CultureInfo.InvariantCulture); Action setLineRate = (node, covered, valid) => { - var rate = valid == 0 ? 1.0 : (double)covered / valid; + var rate = valid == 0 ? 0.0 : (double)covered / valid; node.SetAttributeValue("line-rate", formatDecimal(rate)); }; Func relativePath = path => @@ -282,6 +282,11 @@ var allClasses = document.Descendants("class").ToList(); var linesValid = allClasses.Sum(node => node.Element("lines").Elements("line").Count()); var linesCovered = allClasses.Sum(node => node.Element("lines").Elements("line").Count(line => ((int?)line.Attribute("hits") ?? 0) > 0)); + if (linesValid == 0) + { + Log.LogError("Coverage report contains no source lines under {0}: {1}", sourceRoot, InputPath); + return false; + } var coverage = document.Root; setLineRate(coverage, linesCovered, linesValid); coverage.SetAttributeValue("lines-covered", linesCovered.ToString(CultureInfo.InvariantCulture)); @@ -317,11 +322,22 @@ var coverage = document.Root; double actual; double minimum; + int linesValid; if (coverage == null || !Double.TryParse((string)coverage.Attribute("line-rate"), NumberStyles.Float, CultureInfo.InvariantCulture, out actual)) { Log.LogError("Coverage report does not contain a valid root line-rate: {0}", InputPath); return false; } + if (!Int32.TryParse((string)coverage.Attribute("lines-valid"), NumberStyles.Integer, CultureInfo.InvariantCulture, out linesValid) || linesValid <= 0) + { + Log.LogError("Coverage report contains no valid source lines: {0}", InputPath); + return false; + } + if (Double.IsNaN(actual) || Double.IsInfinity(actual) || actual < 0.0 || actual > 1.0) + { + Log.LogError("Coverage report contains an invalid line-rate {0}: {1}", actual, InputPath); + return false; + } if (!Double.TryParse(MinimumLineRate, NumberStyles.Float, CultureInfo.InvariantCulture, out minimum)) { Log.LogError("MinimumLineRate is not a valid decimal value: {0}", MinimumLineRate); @@ -355,6 +371,7 @@ $(BuildDir)\reports\coverage $(CoverageWorkDir)\coverage.raw.cobertura.xml $(CoverageReportsDir)\coverage.cobertura.xml + $(ProjectRoot)tests\CodeCoverage.config 0.70 $(BuildDir)\dist $(PackageRoot)\MarkdownTableEditor @@ -482,6 +499,10 @@ + + + + @@ -494,7 +515,8 @@ - + &1 + $exitCode = $LASTEXITCODE + $text = $output | Out-String + if ($exitCode -eq 0) { + throw "$Scenario unexpectedly accepted unsafe WorkDir '$Path'." + } + if ($text -notmatch [regex]::Escape($ExpectedMessage)) { + throw "$Scenario failed for an unexpected reason:`n$text" + } +} + +Assert-WorkDirRejected -Path $projectRoot -Scenario "Project-root boundary" +Assert-WorkDirRejected -Path ($projectRoot + "-sibling") -Scenario "Sibling-prefix boundary" + +$probeDir = Join-Path $projectRoot "build\path-safety-tests" +$probeFile = Join-Path $probeDir "not-a-directory" +New-Item -ItemType Directory -Path $probeDir -Force | Out-Null +Set-Content -LiteralPath $probeFile -Value "sentinel" -Encoding Ascii -NoNewline +try { + Assert-WorkDirRejected -Path $probeFile -Scenario "File WorkDir boundary" -ExpectedMessage "WorkDir must be a directory, not a file" + if ((Get-Content -LiteralPath $probeFile -Raw) -ne "sentinel") { + throw "File WorkDir boundary modified its sentinel file." + } +} +finally { + $resolvedProbeDir = [IO.Path]::GetFullPath($probeDir) + $expectedProbeDir = [IO.Path]::GetFullPath((Join-Path $projectRoot "build\path-safety-tests")) + if (-not $resolvedProbeDir.Equals($expectedProbeDir, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to remove unexpected safety-test directory: $resolvedProbeDir" + } + Remove-Item -LiteralPath $resolvedProbeDir -Recurse -Force +} + +Write-Host "Notepad++ compatibility smoke path-safety tests passed" diff --git a/src/MarkdownTableCore.cpp b/src/MarkdownTableCore.cpp index 9880005..aa4c736 100644 --- a/src/MarkdownTableCore.cpp +++ b/src/MarkdownTableCore.cpp @@ -623,6 +623,42 @@ std::size_t nextUtf8Offset(const std::string &text, std::size_t offset) return offset; } +std::size_t nextDisplayClusterOffset(const std::string &text, std::size_t offset) +{ + const unsigned int baseCodePoint = readUtf8CodePoint(text, offset); + + if (isKeycapBase(baseCodePoint)) + { + const std::size_t keycapEnd = skipKeycapCluster(text, offset); + if (keycapEnd != static_cast(-1)) + return keycapEnd; + } + + if (isRegionalIndicator(baseCodePoint) && offset < text.size()) + { + std::size_t nextOffset = offset; + const unsigned int nextCodePoint = readUtf8CodePoint(text, nextOffset); + if (isRegionalIndicator(nextCodePoint)) + return nextOffset; + } + + std::size_t ignoredWidth = codePointDisplayWidth(baseCodePoint); + offset = skipClusterModifiers(text, offset, baseCodePoint, ignoredWidth); + while (offset < text.size()) + { + std::size_t joinerOffset = offset; + if (readUtf8CodePoint(text, joinerOffset) != 0x200D) + break; + + offset = joinerOffset; + if (offset >= text.size()) + break; + const unsigned int joinedCodePoint = readUtf8CodePoint(text, offset); + offset = skipClusterModifiers(text, offset, joinedCodePoint, ignoredWidth); + } + return offset; +} + bool startsMarkdownLinkAt(const std::string &text, std::size_t offset) { if (offset >= text.size() || text[offset] != '[') @@ -704,16 +740,16 @@ std::size_t longTokenChunkEnd(const std::string &token, std::size_t offset, std: while (end < token.size()) { const std::size_t before = end; - const std::size_t after = nextUtf8Offset(token, before); - const std::size_t codePointWidth = displayWidth(token.substr(before, after - before)); - if (before > offset && chunkWidth + codePointWidth > targetWidth) + const std::size_t after = nextDisplayClusterOffset(token, before); + const std::size_t clusterWidth = displayWidth(token.substr(before, after - before)); + if (before > offset && chunkWidth + clusterWidth > targetWidth) return before; - chunkWidth += codePointWidth; + chunkWidth += clusterWidth; end = after; if (chunkWidth >= targetWidth) return end; } - return end > offset ? end : nextUtf8Offset(token, offset); + return end > offset ? end : nextDisplayClusterOffset(token, offset); } void appendWrappedToken(std::vector &segments, std::string ¤t, const std::string &token, std::size_t width) @@ -911,7 +947,11 @@ std::size_t widthSum(const std::vector &widths) { std::size_t sum = 0; for (std::size_t i = 0; i < widths.size(); ++i) + { + if (widths[i] > (std::numeric_limits::max)() - sum) + return (std::numeric_limits::max)(); sum += widths[i]; + } return sum; } @@ -945,53 +985,113 @@ std::vector wrappableColumns(const Table &table, const std::vector &widths, const std::vector &minimums, const std::vector &allowed) +std::size_t reductionToSlackCap( + const std::vector &widths, + const std::vector &minimums, + const std::vector &allowed, + std::size_t slackCap) { - std::size_t best = static_cast(-1); - std::size_t bestSlack = 0; + std::size_t reduction = 0; for (std::size_t column = 0; column < widths.size(); ++column) { - if (!allowed[column] || widths[column] <= minimums[column]) + if (!allowed[column]) continue; - - const std::size_t slack = widths[column] - minimums[column]; - if (best == static_cast(-1) || slack > bestSlack) + const std::size_t slack = widths[column] > minimums[column] ? widths[column] - minimums[column] : 0; + if (slack > slackCap) { - best = column; - bestSlack = slack; + const std::size_t amount = slack - slackCap; + if (amount > (std::numeric_limits::max)() - reduction) + return (std::numeric_limits::max)(); + reduction += amount; } } - return best; + return reduction; } void shrinkColumnsToBudget(std::vector &widths, const std::vector &minimums, const std::vector &allowed, std::size_t budget) { - while (widthSum(widths) > budget) + const std::size_t currentWidth = widthSum(widths); + if (currentWidth <= budget) + return; + + const std::size_t requestedReduction = currentWidth - budget; + std::size_t totalSlack = 0; + std::size_t maxSlack = 0; + for (std::size_t column = 0; column < widths.size(); ++column) + { + if (!allowed[column]) + continue; + const std::size_t slack = widths[column] > minimums[column] ? widths[column] - minimums[column] : 0; + maxSlack = (std::max)(maxSlack, slack); + if (slack > (std::numeric_limits::max)() - totalSlack) + totalSlack = (std::numeric_limits::max)(); + else + totalSlack += slack; + } + + const std::size_t reduction = (std::min)(requestedReduction, totalSlack); + if (reduction == 0) + return; + + std::size_t low = 0; + std::size_t high = maxSlack; + while (low < high) { - const std::size_t column = largestShrinkableColumn(widths, minimums, allowed); - if (column == static_cast(-1)) - return; - --widths[column]; + const std::size_t cap = low + (high - low) / 2; + if (reductionToSlackCap(widths, minimums, allowed, cap) <= reduction) + high = cap; + else + low = cap + 1; + } + + const std::size_t slackCap = low; + std::size_t remaining = reduction - reductionToSlackCap(widths, minimums, allowed, slackCap); + for (std::size_t column = 0; column < widths.size(); ++column) + { + if (!allowed[column]) + continue; + const std::size_t minimum = minimums[column]; + const std::size_t slack = widths[column] > minimum ? widths[column] - minimum : 0; + std::size_t targetSlack = (std::min)(slack, slackCap); + if (remaining > 0 && targetSlack == slackCap && targetSlack > 0) + { + --targetSlack; + --remaining; + } + widths[column] = minimum + targetSlack; } } -std::size_t bestGrowableColumn(const std::vector &widths, const std::vector &naturalWidths, const std::vector &allowed) +void growColumnsToBudget( + std::vector &widths, + const std::vector &naturalWidths, + const std::vector &allowed, + std::size_t budget) { - std::size_t best = static_cast(-1); - std::size_t bestSlack = 0; + const std::size_t currentWidth = widthSum(widths); + if (currentWidth >= budget) + return; + + std::vector deficits(widths.size(), 0); + std::vector zeroes(widths.size(), 0); + std::size_t totalDeficit = 0; for (std::size_t column = 0; column < widths.size(); ++column) { if (!allowed[column] || widths[column] >= naturalWidths[column]) continue; - - const std::size_t slack = naturalWidths[column] - widths[column]; - if (best == static_cast(-1) || slack > bestSlack) - { - best = column; - bestSlack = slack; - } + deficits[column] = naturalWidths[column] - widths[column]; + if (deficits[column] > (std::numeric_limits::max)() - totalDeficit) + totalDeficit = (std::numeric_limits::max)(); + else + totalDeficit += deficits[column]; + } + const std::size_t available = (std::min)(budget - currentWidth, totalDeficit); + shrinkColumnsToBudget(deficits, zeroes, allowed, totalDeficit - available); + for (std::size_t column = 0; column < widths.size(); ++column) + { + if (allowed[column]) + widths[column] = naturalWidths[column] - deficits[column]; } - return best; } std::vector targetColumnWidthsForTableWidth(const Table &table, std::size_t maxTableWidth) @@ -1040,13 +1140,7 @@ std::vector targetColumnWidthsForTableWidth(const Table &table, std shrinkColumnsToBudget(widths, hardMinimums, allColumns, budget); } - while (widthSum(widths) < budget) - { - const std::size_t column = bestGrowableColumn(widths, naturalWidths, canWrap); - if (column == static_cast(-1)) - break; - ++widths[column]; - } + growColumnsToBudget(widths, naturalWidths, canWrap, budget); return widths; } @@ -1886,7 +1980,6 @@ void setResultFromFormat(EditResult &result, FormatResult &formatted) char detectDelimiter(const std::string &text) { std::size_t tabs = 0; - std::size_t commas = 0; bool inQuotes = false; bool cellBlank = true; @@ -1911,7 +2004,6 @@ char detectDelimiter(const std::string &text) } else if (ch == ',') { - ++commas; cellBlank = true; } else if (ch == '\r' || ch == '\n') @@ -1924,7 +2016,7 @@ char detectDelimiter(const std::string &text) } } - return tabs > commas ? '\t' : ','; + return tabs > 0 ? '\t' : ','; } bool hasDelimitedStructure(const std::string &text) @@ -1934,6 +2026,7 @@ bool hasDelimitedStructure(const std::string &text) return false; bool inQuotes = false; bool cellBlank = true; + bool foundDelimiter = false; for (std::size_t i = 0; i < value.size(); ++i) { const char ch = value[i]; @@ -1950,7 +2043,8 @@ bool hasDelimitedStructure(const std::string &text) } else if (ch == ',' || ch == '\t') { - return true; + foundDelimiter = true; + cellBlank = true; } else if (ch == '\r' || ch == '\n') { @@ -1961,7 +2055,7 @@ bool hasDelimitedStructure(const std::string &text) cellBlank = false; } } - return false; + return foundDelimiter && !inQuotes; } bool hasCellContent(const std::vector &row) @@ -1980,6 +2074,8 @@ std::vector > parseDelimitedRows(const std::string &tex std::vector row; std::string cell; bool inQuotes = false; + bool closedQuotedField = false; + bool recordHasDelimitedSyntax = false; for (std::size_t i = 0; i < text.size(); ++i) { @@ -1996,6 +2092,7 @@ std::vector > parseDelimitedRows(const std::string &tex else { inQuotes = false; + closedQuotedField = true; } } else if (ch == '\r' || ch == '\n') @@ -2011,10 +2108,43 @@ std::vector > parseDelimitedRows(const std::string &tex continue; } + if (closedQuotedField) + { + if (ch == delimiter) + { + row.push_back(trim(cell)); + cell.clear(); + closedQuotedField = false; + recordHasDelimitedSyntax = true; + } + else if (ch == '\r' || ch == '\n') + { + row.push_back(trim(cell)); + cell.clear(); + if (hasCellContent(row) || recordHasDelimitedSyntax) + rows.push_back(row); + row.clear(); + closedQuotedField = false; + recordHasDelimitedSyntax = false; + if (ch == '\r' && i + 1 < text.size() && text[i + 1] == '\n') + ++i; + } + else if (isSpace(static_cast(ch))) + { + cell += ch; + } + else + { + return std::vector >(); + } + continue; + } + if (ch == '"' && trim(cell).empty()) { cell.clear(); inQuotes = true; + recordHasDelimitedSyntax = true; continue; } @@ -2022,6 +2152,7 @@ std::vector > parseDelimitedRows(const std::string &tex { row.push_back(trim(cell)); cell.clear(); + recordHasDelimitedSyntax = true; continue; } @@ -2029,9 +2160,10 @@ std::vector > parseDelimitedRows(const std::string &tex { row.push_back(trim(cell)); cell.clear(); - if (hasCellContent(row)) + if (hasCellContent(row) || recordHasDelimitedSyntax) rows.push_back(row); row.clear(); + recordHasDelimitedSyntax = false; if (ch == '\r' && i + 1 < text.size() && text[i + 1] == '\n') ++i; continue; @@ -2040,8 +2172,11 @@ std::vector > parseDelimitedRows(const std::string &tex cell += ch; } + if (inQuotes) + return std::vector >(); + row.push_back(trim(cell)); - if (hasCellContent(row)) + if (hasCellContent(row) || recordHasDelimitedSyntax) rows.push_back(row); return rows; } @@ -2052,7 +2187,7 @@ std::string escapeMarkdownCell(const std::string &cell) for (std::size_t i = 0; i < cell.size(); ++i) { const char ch = cell[i]; - if (ch == '|') + if (ch == '|' && !isEscaped(cell, i)) escaped += "\\|"; else if (ch == '\r' || ch == '\n') escaped += ' '; @@ -2169,11 +2304,15 @@ TableRange findTableRange(const std::vector &lines, int row) const std::size_t firstRow = separatorRow - 1; if (!isPotentialTableLine(lines[firstRow])) continue; + if (!isPotentialTableLine(lines[separatorRow])) + continue; Row separator; separator.cells = splitCells(lines[separatorRow]); if (!isSeparatorRow(separator) && !isShortSeparatorLine(lines[separatorRow])) continue; + if (splitCells(lines[firstRow]).size() != separator.cells.size()) + continue; const std::size_t lastRow = tableRangeEnd(lines, firstRow, separatorRow); if (targetRow >= firstRow && targetRow <= lastRow) diff --git a/src/MarkdownTableEditorPlugin.cpp b/src/MarkdownTableEditorPlugin.cpp index 49ca03c..bcdd896 100644 --- a/src/MarkdownTableEditorPlugin.cpp +++ b/src/MarkdownTableEditorPlugin.cpp @@ -23,27 +23,11 @@ extern NppData nppData; BOOL APIENTRY DllMain(HANDLE hModule, DWORD reasonForCall, LPVOID /*lpReserved*/) { - try { - - switch (reasonForCall) - { - case DLL_PROCESS_ATTACH: - pluginInit(hModule); - break; - - case DLL_PROCESS_DETACH: - pluginCleanUp(); - break; - - case DLL_THREAD_ATTACH: - break; - - case DLL_THREAD_DETACH: - break; - } + if (reasonForCall == DLL_PROCESS_ATTACH) + { + pluginInit(hModule); + ::DisableThreadLibraryCalls(reinterpret_cast(hModule)); } - catch (...) { return FALSE; } - return TRUE; } @@ -61,13 +45,16 @@ extern "C" __declspec(dllexport) const TCHAR * getName() extern "C" __declspec(dllexport) FuncItem * getFuncsArray(int *nbF) { - *nbF = nbFunc; + if (nbF) + *nbF = nbFunc; return funcItem; } extern "C" __declspec(dllexport) void beNotified(SCNotification *notifyCode) { + if (!notifyCode) + return; switch (notifyCode->nmhdr.code) { case NPPN_READY: @@ -141,7 +128,7 @@ extern "C" __declspec(dllexport) void beNotified(SCNotification *notifyCode) case NPPN_SHUTDOWN: { - removeFitToWindowResizeHooks(); + pluginCleanUp(); commandMenuCleanUp(); } break; diff --git a/src/PluginDefinition.cpp b/src/PluginDefinition.cpp index 7eaa146..5a362e3 100644 --- a/src/PluginDefinition.cpp +++ b/src/PluginDefinition.cpp @@ -31,6 +31,8 @@ #include #include +#pragma comment(lib, "comctl32.lib") + // // The plugin data that Notepad++ needs // @@ -195,9 +197,9 @@ std::size_t g_lastFitToWindowColumns = 0; std::vector g_initialAutoFormatBuffers; std::vector g_pendingInitialAutoFormatBuffers; HWND g_pendingFitToWindowScintilla = NULL; -WNDPROC g_originalMainScintillaProc = NULL; -WNDPROC g_originalSecondScintillaProc = NULL; -WNDPROC g_originalNppProc = NULL; +bool g_mainScintillaSubclassInstalled = false; +bool g_secondScintillaSubclassInstalled = false; +bool g_nppSubclassInstalled = false; HBITMAP g_toolbarBmps[nbFunc] = {}; HICON g_toolbarIcons[nbFunc] = {}; HICON g_toolbarIconDarkModes[nbFunc] = {}; @@ -2742,21 +2744,17 @@ void cancelFitToWindowResizeTimers() cancelFitToWindowResizeTimer(nppData._scintillaSecondHandle); } -WNDPROC originalScintillaProc(HWND hwnd) -{ - if (hwnd == nppData._scintillaMainHandle) - return g_originalMainScintillaProc; - if (hwnd == nppData._scintillaSecondHandle) - return g_originalSecondScintillaProc; - return NULL; -} +const UINT_PTR fitToWindowScintillaSubclassId = 0x4D544553; +const UINT_PTR fitToWindowNppSubclassId = 0x4D54454E; -LRESULT CALLBACK fitToWindowScintillaProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) +LRESULT CALLBACK fitToWindowScintillaProc( + HWND hwnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR /*subclassId*/, + DWORD_PTR /*referenceData*/) { - WNDPROC original = originalScintillaProc(hwnd); - if (!original) - return ::DefWindowProc(hwnd, message, wParam, lParam); - if (message == WM_TIMER && wParam == fitToWindowResizeTimerId) { cancelFitToWindowResizeTimer(hwnd); @@ -2766,7 +2764,7 @@ LRESULT CALLBACK fitToWindowScintillaProc(HWND hwnd, UINT message, WPARAM wParam std::size_t preservedEnterColumn = 0; const bool preserveEnterColumn = captureEnterColumnToPreserve(hwnd, message, wParam, preservedEnterColumn); - const LRESULT result = ::CallWindowProc(original, hwnd, message, wParam, lParam); + const LRESULT result = ::DefSubclassProc(hwnd, message, wParam, lParam); if (preserveEnterColumn) restoreEnterColumn(hwnd, preservedEnterColumn); if (shouldScheduleFitToWindowAfterResizeMessage(message, wParam, lParam)) @@ -2774,68 +2772,46 @@ LRESULT CALLBACK fitToWindowScintillaProc(HWND hwnd, UINT message, WPARAM wParam return result; } -LRESULT CALLBACK fitToWindowNppProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) +LRESULT CALLBACK fitToWindowNppProc( + HWND hwnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR /*subclassId*/, + DWORD_PTR /*referenceData*/) { - WNDPROC original = g_originalNppProc; - if (!original) - return ::DefWindowProc(hwnd, message, wParam, lParam); - - const LRESULT result = ::CallWindowProc(original, hwnd, message, wParam, lParam); + const LRESULT result = ::DefSubclassProc(hwnd, message, wParam, lParam); if (shouldScheduleFitToWindowAfterResizeMessage(message, wParam, lParam)) scheduleFitToWindowAfterResize(currentScintilla()); return result; } -void subclassScintillaWindow(HWND hwnd, WNDPROC &original) +void subclassScintillaWindow(HWND hwnd, bool &installed) { - if (!hwnd || original) + if (!hwnd || installed) return; - - original = reinterpret_cast(::SetWindowLongPtr( - hwnd, - GWLP_WNDPROC, - reinterpret_cast(fitToWindowScintillaProc))); + installed = ::SetWindowSubclass(hwnd, fitToWindowScintillaProc, fitToWindowScintillaSubclassId, 0) != FALSE; } void subclassNppWindow() { - if (!nppData._nppHandle || g_originalNppProc) + if (!nppData._nppHandle || g_nppSubclassInstalled) return; - - g_originalNppProc = reinterpret_cast(::SetWindowLongPtr( - nppData._nppHandle, - GWLP_WNDPROC, - reinterpret_cast(fitToWindowNppProc))); + g_nppSubclassInstalled = ::SetWindowSubclass(nppData._nppHandle, fitToWindowNppProc, fitToWindowNppSubclassId, 0) != FALSE; } -void removeScintillaSubclass(HWND hwnd, WNDPROC &original) +void removeScintillaSubclass(HWND hwnd, bool &installed) { - if (!hwnd || !original) - { - original = NULL; - return; - } - - if (reinterpret_cast(::GetWindowLongPtr(hwnd, GWLP_WNDPROC)) == fitToWindowScintillaProc) - { - ::SetWindowLongPtr(hwnd, GWLP_WNDPROC, reinterpret_cast(original)); - } - original = NULL; + if (hwnd && installed) + ::RemoveWindowSubclass(hwnd, fitToWindowScintillaProc, fitToWindowScintillaSubclassId); + installed = false; } void removeNppSubclass() { - if (!nppData._nppHandle || !g_originalNppProc) - { - g_originalNppProc = NULL; - return; - } - - if (reinterpret_cast(::GetWindowLongPtr(nppData._nppHandle, GWLP_WNDPROC)) == fitToWindowNppProc) - { - ::SetWindowLongPtr(nppData._nppHandle, GWLP_WNDPROC, reinterpret_cast(g_originalNppProc)); - } - g_originalNppProc = NULL; + if (nppData._nppHandle && g_nppSubclassInstalled) + ::RemoveWindowSubclass(nppData._nppHandle, fitToWindowNppProc, fitToWindowNppSubclassId); + g_nppSubclassInstalled = false; } std::string getRangeText(HWND scintilla, Sci_Position start, Sci_Position end) @@ -4908,15 +4884,15 @@ void toggleAutoAlignTable() void installFitToWindowResizeHooks() { subclassNppWindow(); - subclassScintillaWindow(nppData._scintillaMainHandle, g_originalMainScintillaProc); - subclassScintillaWindow(nppData._scintillaSecondHandle, g_originalSecondScintillaProc); + subclassScintillaWindow(nppData._scintillaMainHandle, g_mainScintillaSubclassInstalled); + subclassScintillaWindow(nppData._scintillaSecondHandle, g_secondScintillaSubclassInstalled); rememberCurrentFitToWindowWidth(); } void removeFitToWindowResizeHooks() { cancelFitToWindowResizeTimers(); - removeScintillaSubclass(nppData._scintillaMainHandle, g_originalMainScintillaProc); - removeScintillaSubclass(nppData._scintillaSecondHandle, g_originalSecondScintillaProc); + removeScintillaSubclass(nppData._scintillaMainHandle, g_mainScintillaSubclassInstalled); + removeScintillaSubclass(nppData._scintillaSecondHandle, g_secondScintillaSubclassInstalled); removeNppSubclass(); } diff --git a/test-fixtures/markdown-table-core-golden.json b/test-fixtures/markdown-table-core-golden.json index f643b3d..943ea50 100644 --- a/test-fixtures/markdown-table-core-golden.json +++ b/test-fixtures/markdown-table-core-golden.json @@ -19,6 +19,55 @@ "| Anna | A\\|B |" ] }, + { + "name": "csv preserves escaped pipes and fixes even slash parity", + "input": "Name,Note\nAnna,a\\|b\nBob,a\\\\|b", + "lines": [ + "| Name | Note |", + "| ---- | ------ |", + "| Anna | a\\|b |", + "| Bob | a\\\\\\|b |" + ] + }, + { + "name": "tsv delimiter wins when cell commas are more frequent", + "input": "Name\tNote\nAnna\tone,two,three,four\nBob\tplain", + "lines": [ + "| Name | Note |", + "| ---- | ------------------ |", + "| Anna | one,two,three,four |", + "| Bob | plain |" + ] + }, + { + "name": "unterminated quoted csv field is rejected", + "input": "Name,Note\nAnna,\"unfinished", + "ok": false + }, + { + "name": "characters after closing csv quote are rejected", + "input": "Name,Note\nAnna,\"quoted\"junk", + "ok": false + }, + { + "name": "csv preserves an explicitly empty record", + "input": "A,B\n,\nC,D", + "lines": [ + "| A | B |", + "| --- | --- |", + "| | |", + "| C | D |" + ] + }, + { + "name": "csv trims surrounding cell spaces", + "input": "Name,Quoted,Unquoted\nAnna,\" kept \", trim ", + "lines": [ + "| Name | Quoted | Unquoted |", + "| ---- | ------ | -------- |", + "| Anna | kept | trim |" + ] + }, { "name": "csv crlf and multiline quoted cell", "input": "Name,Note\r\nAnna,\"line 1\r\nline 2\"\r\nBob,done\r\n", @@ -64,6 +113,28 @@ } ], "edits": [ + { + "name": "setext heading is not a markdown table", + "action": "ALIGN", + "row": 0, + "column": 0, + "input": [ + "Title A | Title B", + "---" + ], + "ok": false + }, + { + "name": "separator column count must match header", + "action": "ALIGN", + "row": 0, + "column": 0, + "input": [ + "A | B | C", + "--- | ---" + ], + "ok": false + }, { "name": "unicode width align", "action": "ALIGN", @@ -291,6 +362,25 @@ "targetRow": 2, "targetColumn": 0 }, + { + "name": "wrap long cells keeps combining grapheme cluster intact", + "action": "WRAP_LONG_CELLS", + "row": 2, + "column": 0, + "input": [ + "| C |", + "| --- |", + "| 1234567890123456789012345a\u0301z |" + ], + "lines": [ + "| C |", + "| -------------------------- |", + "| 1234567890123456789012345a\u0301 |", + "| z |" + ], + "targetRow": 2, + "targetColumn": 0 + }, { "name": "sort rows ascending decimal numbers", "action": "SORT_ASCENDING", diff --git a/tests/CodeCoverage.config b/tests/CodeCoverage.config new file mode 100644 index 0000000..35ce4df --- /dev/null +++ b/tests/CodeCoverage.config @@ -0,0 +1,16 @@ + + + + + + .*CoreSmoke\.exe$ + + + . + + + True + False + True + + diff --git a/tests/CorePerformance.cpp b/tests/CorePerformance.cpp index e061c1b..b8953c2 100644 --- a/tests/CorePerformance.cpp +++ b/tests/CorePerformance.cpp @@ -367,6 +367,12 @@ int main() const std::string tsv = delimited(5000, 8, '\t'); const std::vector sortable = sortableTable(5000, 8); const std::vector operationTable = table(1500, 16, true); + const std::vector hugeHeaderTable = + { + "| " + std::string(1000000, 'x') + " |", + "| --- |", + "| value |" + }; const std::vector benchmarks = { @@ -399,6 +405,11 @@ int main() consume(MarkdownTable::apply(sortable, 2500, 1, MarkdownTable::Action::SortRowsDescending)); } }, + { + "fit 1000000-char header", + 1500.0, + [&hugeHeaderTable]() { consume(MarkdownTable::applyWrappedToWidth(hugeHeaderTable, 2, 0, 80)); } + }, { "row and column operations", 1000.0, diff --git a/tests/CoreSmoke.cpp b/tests/CoreSmoke.cpp index 497e99b..0c31fed 100644 --- a/tests/CoreSmoke.cpp +++ b/tests/CoreSmoke.cpp @@ -746,6 +746,24 @@ int main() "| Anna | A\\|B |" }); + expectLines("tsv preserves an already escaped pipe", MarkdownTable::convertDelimitedToTable( + R"(Name Note +Anna A\|B)").lines, + { + "| Name | Note |", + "| ---- | ---- |", + R"(| Anna | A\|B |)" + }); + + expectLines("tsv fixes even backslash parity before a pipe", MarkdownTable::convertDelimitedToTable( + R"(Name Note +Anna A\\|B)").lines, + { + "| Name | Note |", + "| ---- | ------ |", + R"(| Anna | A\\\|B |)" + }); + expectLines("tsv to table pads uneven rows", MarkdownTable::convertDelimitedToTable("A\tB\tC\n1\t2\n3\t4\t5").lines, { "| A | B | C |", @@ -788,7 +806,47 @@ int main() expectString("plain text csv message", MarkdownTable::convertDelimitedToTable("just a note").message, "No CSV or TSV data found"); expectTrue("plain multiline text is not csv", !MarkdownTable::convertDelimitedToTable("first line\nsecond line").ok); expectTrue("single quoted comma cell is not csv", !MarkdownTable::convertDelimitedToTable("\"just, a note\"").ok); + expectTrue("unterminated quoted csv is rejected", !MarkdownTable::convertDelimitedToTable("Name,Note\nAnna,\"unterminated").ok); + expectTrue("characters after a closing csv quote are rejected", !MarkdownTable::convertDelimitedToTable("Name,Note\nAnna,\"quoted\"junk").ok); + expectTrue("whitespace after a closing csv quote is accepted", MarkdownTable::convertDelimitedToTable("Name,Note\nAnna,\"quoted\" ").ok); + expectLines("csv preserves an explicitly empty record", MarkdownTable::convertDelimitedToTable("A,B\n,\nC,D").lines, + { + "| A | B |", + "| --- | --- |", + "| | |", + "| C | D |" + }); + expectLines("csv trims surrounding cell spaces", MarkdownTable::convertDelimitedToTable("Name,Quoted,Unquoted\nAnna,\" kept \", trim ").lines, + { + "| Name | Quoted | Unquoted |", + "| ---- | ------ | -------- |", + "| Anna | kept | trim |" + }); expectTrue("empty csv rejected", !MarkdownTable::convertDelimitedToTable(" \r\n\t").ok); + expectTrue("setext heading is not mistaken for a table", !MarkdownTable::apply( + { + "A | B", + "---" + }, + 0, + 0, + MarkdownTable::Action::Align).ok); + expectTrue("one-column table with explicit pipes remains valid", MarkdownTable::apply( + { + "| A |", + "| --- |" + }, + 0, + 0, + MarkdownTable::Action::Align).ok); + expectTrue("separator column count must match header", !MarkdownTable::apply( + { + "A | B | C", + "--- | ---" + }, + 0, + 0, + MarkdownTable::Action::Align).ok); expectLines("keep markdown header row", MarkdownTable::apply( { diff --git a/tests/CoreSmoke.vcxproj b/tests/CoreSmoke.vcxproj index c83ad0a..60dbec9 100644 --- a/tests/CoreSmoke.vcxproj +++ b/tests/CoreSmoke.vcxproj @@ -51,6 +51,9 @@ + + false + CoreSmoke $(ProjectDir) @@ -60,6 +63,7 @@ Level4 Disabled + ProgramDatabase ..\src;%(AdditionalIncludeDirectories) true stdcpp14 @@ -67,6 +71,8 @@ Console true + true + true diff --git a/tests/GoldenFixtureTests.cpp b/tests/GoldenFixtureTests.cpp index ffe77a6..c635ab1 100644 --- a/tests/GoldenFixtureTests.cpp +++ b/tests/GoldenFixtureTests.cpp @@ -88,6 +88,13 @@ std::size_t asSize(const JsonValue &value) return value.get(); } +bool asBool(const JsonValue &value) +{ + if (!value.is_boolean()) + throw std::runtime_error("Expected JSON boolean"); + return value.get(); +} + std::vector asStringVector(const JsonValue &value) { if (!value.is_array()) @@ -167,8 +174,10 @@ void runConversionScenarios(const JsonValue &scenarios) const JsonValue &scenario = *item; const std::string name = asString(member(scenario, "name")); const MarkdownTable::EditResult result = MarkdownTable::convertDelimitedToTable(asString(member(scenario, "input"))); - expectTrue(name, result.ok, std::string("should convert: ") + result.message); - expectLines(name, result.lines, asStringVector(member(scenario, "lines"))); + const bool expectedOk = !hasMember(scenario, "ok") || asBool(member(scenario, "ok")); + expectTrue(name, result.ok == expectedOk, std::string("unexpected conversion status: ") + result.message); + if (expectedOk) + expectLines(name, result.lines, asStringVector(member(scenario, "lines"))); } } @@ -186,7 +195,10 @@ void runEditScenarios(const JsonValue &scenarios) static_cast(asSize(member(scenario, "row"))), static_cast(asSize(member(scenario, "column"))), actionFromString(asString(member(scenario, "action")))); - expectTrue(name, result.ok, std::string("should apply: ") + result.message); + const bool expectedOk = !hasMember(scenario, "ok") || asBool(member(scenario, "ok")); + expectTrue(name, result.ok == expectedOk, std::string("unexpected edit status: ") + result.message); + if (!expectedOk) + continue; expectLines(name, result.lines, asStringVector(member(scenario, "lines"))); if (hasMember(scenario, "targetRow")) From a5920eda677324e858ca1d195acc2e6635e72ff8 Mon Sep 17 00:00:00 2001 From: "Andrei.Ovcharenko" Date: Sat, 11 Jul 2026 08:32:36 +0300 Subject: [PATCH 2/4] Support Windows PowerShell path validation --- scripts/Invoke-NppCompatibilitySmoke.ps1 | 2 +- scripts/Test-NppCompatibilitySmokeSafety.ps1 | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/Invoke-NppCompatibilitySmoke.ps1 b/scripts/Invoke-NppCompatibilitySmoke.ps1 index 9b9911a..de7a08e 100644 --- a/scripts/Invoke-NppCompatibilitySmoke.ps1 +++ b/scripts/Invoke-NppCompatibilitySmoke.ps1 @@ -31,7 +31,7 @@ function Assert-NoReparsePointsBelowRoot([string]$Path, [string]$Root) { throw "Refusing to use a WorkDir path that crosses a reparse point: $($item.FullName)" } } - $parent = Split-Path -LiteralPath $current -Parent + $parent = [IO.Path]::GetDirectoryName($current) if (-not $parent -or $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) { throw "Could not validate WorkDir ancestry: $Path" } diff --git a/scripts/Test-NppCompatibilitySmokeSafety.ps1 b/scripts/Test-NppCompatibilitySmokeSafety.ps1 index d0b35f7..ca954c1 100644 --- a/scripts/Test-NppCompatibilitySmokeSafety.ps1 +++ b/scripts/Test-NppCompatibilitySmokeSafety.ps1 @@ -15,6 +15,19 @@ function Assert-WorkDirRejected([string]$Path, [string]$Scenario, [string]$Expec } } +function Assert-SafeWorkDirReachesPluginValidation([string]$Path) { + $missingPlugin = Join-Path $projectRoot "build\path-safety-tests\missing-plugin.dll" + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $smokeScript ` + -WorkDir $Path ` + -Win32PluginPath $missingPlugin ` + -X64PluginPath $missingPlugin 2>&1 + $exitCode = $LASTEXITCODE + $text = $output | Out-String + if ($exitCode -eq 0 -or $text -notmatch "Win32 plugin DLL not found") { + throw "Safe child WorkDir did not reach plugin validation as expected:`n$text" + } +} + Assert-WorkDirRejected -Path $projectRoot -Scenario "Project-root boundary" Assert-WorkDirRejected -Path ($projectRoot + "-sibling") -Scenario "Sibling-prefix boundary" @@ -23,6 +36,7 @@ $probeFile = Join-Path $probeDir "not-a-directory" New-Item -ItemType Directory -Path $probeDir -Force | Out-Null Set-Content -LiteralPath $probeFile -Value "sentinel" -Encoding Ascii -NoNewline try { + Assert-SafeWorkDirReachesPluginValidation -Path (Join-Path $probeDir "safe-child") Assert-WorkDirRejected -Path $probeFile -Scenario "File WorkDir boundary" -ExpectedMessage "WorkDir must be a directory, not a file" if ((Get-Content -LiteralPath $probeFile -Raw) -ne "sentinel") { throw "File WorkDir boundary modified its sentinel file." From f789abef42c5892f565b2946fcdcd66c9f046f88 Mon Sep 17 00:00:00 2001 From: "Andrei.Ovcharenko" Date: Sat, 11 Jul 2026 08:36:15 +0300 Subject: [PATCH 3/4] Capture expected PowerShell probe failures --- scripts/Test-NppCompatibilitySmokeSafety.ps1 | 28 +++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/Test-NppCompatibilitySmokeSafety.ps1 b/scripts/Test-NppCompatibilitySmokeSafety.ps1 index ca954c1..e92a7d4 100644 --- a/scripts/Test-NppCompatibilitySmokeSafety.ps1 +++ b/scripts/Test-NppCompatibilitySmokeSafety.ps1 @@ -4,8 +4,15 @@ $projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) $smokeScript = Join-Path $PSScriptRoot "Invoke-NppCompatibilitySmoke.ps1" function Assert-WorkDirRejected([string]$Path, [string]$Scenario, [string]$ExpectedMessage = "WorkDir must be a child directory under the project root") { - $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $smokeScript -WorkDir $Path 2>&1 - $exitCode = $LASTEXITCODE + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $smokeScript -WorkDir $Path 2>&1 + $exitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorPreference + } $text = $output | Out-String if ($exitCode -eq 0) { throw "$Scenario unexpectedly accepted unsafe WorkDir '$Path'." @@ -17,11 +24,18 @@ function Assert-WorkDirRejected([string]$Path, [string]$Scenario, [string]$Expec function Assert-SafeWorkDirReachesPluginValidation([string]$Path) { $missingPlugin = Join-Path $projectRoot "build\path-safety-tests\missing-plugin.dll" - $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $smokeScript ` - -WorkDir $Path ` - -Win32PluginPath $missingPlugin ` - -X64PluginPath $missingPlugin 2>&1 - $exitCode = $LASTEXITCODE + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $smokeScript ` + -WorkDir $Path ` + -Win32PluginPath $missingPlugin ` + -X64PluginPath $missingPlugin 2>&1 + $exitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorPreference + } $text = $output | Out-String if ($exitCode -eq 0 -or $text -notmatch "Win32 plugin DLL not found") { throw "Safe child WorkDir did not reach plugin validation as expected:`n$text" From d9a861c55d1a2ce0ac620acfd7a3e7320fa1050e Mon Sep 17 00:00:00 2001 From: "Andrei.Ovcharenko" Date: Sat, 11 Jul 2026 08:46:20 +0300 Subject: [PATCH 4/4] Cover grapheme and allocator edge cases --- src/MarkdownTableCore.cpp | 21 +++---------------- tests/CoreSmoke.cpp | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/MarkdownTableCore.cpp b/src/MarkdownTableCore.cpp index aa4c736..688ebc5 100644 --- a/src/MarkdownTableCore.cpp +++ b/src/MarkdownTableCore.cpp @@ -947,11 +947,7 @@ std::size_t widthSum(const std::vector &widths) { std::size_t sum = 0; for (std::size_t i = 0; i < widths.size(); ++i) - { - if (widths[i] > (std::numeric_limits::max)() - sum) - return (std::numeric_limits::max)(); sum += widths[i]; - } return sum; } @@ -998,12 +994,7 @@ std::size_t reductionToSlackCap( continue; const std::size_t slack = widths[column] > minimums[column] ? widths[column] - minimums[column] : 0; if (slack > slackCap) - { - const std::size_t amount = slack - slackCap; - if (amount > (std::numeric_limits::max)() - reduction) - return (std::numeric_limits::max)(); - reduction += amount; - } + reduction += slack - slackCap; } return reduction; } @@ -1023,10 +1014,7 @@ void shrinkColumnsToBudget(std::vector &widths, const std::vector minimums[column] ? widths[column] - minimums[column] : 0; maxSlack = (std::max)(maxSlack, slack); - if (slack > (std::numeric_limits::max)() - totalSlack) - totalSlack = (std::numeric_limits::max)(); - else - totalSlack += slack; + totalSlack += slack; } const std::size_t reduction = (std::min)(requestedReduction, totalSlack); @@ -1080,10 +1068,7 @@ void growColumnsToBudget( if (!allowed[column] || widths[column] >= naturalWidths[column]) continue; deficits[column] = naturalWidths[column] - widths[column]; - if (deficits[column] > (std::numeric_limits::max)() - totalDeficit) - totalDeficit = (std::numeric_limits::max)(); - else - totalDeficit += deficits[column]; + totalDeficit += deficits[column]; } const std::size_t available = (std::min)(budget - currentWidth, totalDeficit); shrinkColumnsToBudget(deficits, zeroes, allowed, totalDeficit - available); diff --git a/tests/CoreSmoke.cpp b/tests/CoreSmoke.cpp index 0c31fed..96b337b 100644 --- a/tests/CoreSmoke.cpp +++ b/tests/CoreSmoke.cpp @@ -221,6 +221,31 @@ int main() "| | epsilon zeta eta theta |" }); + const std::vector displayClusters = + { + "1\xEF\xB8\x8F\xE2\x83\xA3", + "\xF0\x9F\x87\xBA\xF0\x9F\x87\xB8", + "\xF0\x9F\x91\xA9\xE2\x80\x8D\xF0\x9F\x92\xBB" + }; + for (std::size_t clusterIndex = 0; clusterIndex < displayClusters.size(); ++clusterIndex) + { + const std::string &cluster = displayClusters[clusterIndex]; + const MarkdownTable::EditResult clusterWrapped = MarkdownTable::apply( + { + "| Value |", + "| --- |", + "| " + std::string(25, 'x') + cluster + "z |" + }, + 2, + 0, + MarkdownTable::Action::WrapLongCells); + expectTrue("wrap display cluster ok", clusterWrapped.ok); + bool clusterKeptIntact = false; + for (std::size_t lineIndex = 0; lineIndex < clusterWrapped.lines.size(); ++lineIndex) + clusterKeptIntact = clusterKeptIntact || clusterWrapped.lines[lineIndex].find(cluster) != std::string::npos; + expectTrue("wrap keeps display cluster intact", clusterKeptIntact); + } + const MarkdownTable::EditResult narrowedColumn = MarkdownTable::apply( { "| Key | Value |", @@ -311,6 +336,18 @@ int main() expectTrue("expanded auto wrap reduces continuation rows", expandedWrapped.lines.size() < autoWrapped.lines.size()); expectLineLengthAtMost("expanded auto wrap keeps physical lines inside wider width", expandedWrapped.lines, 150); + const MarkdownTable::EditResult equalSlackWrapped = MarkdownTable::applyWrappedToWidth( + { + "| A | B |", + "| --- | --- |", + "| abcde | vwxyz |" + }, + 2, + 0, + 16); + expectTrue("equal slack auto wrap ok", equalSlackWrapped.ok); + expectLineLengthAtMost("equal slack auto wrap honors width", equalSlackWrapped.lines, 16); + const MarkdownTable::EditResult sparseLowerRow = MarkdownTable::applyWrappedToWidth( { "| Step | Note |", @@ -809,6 +846,12 @@ Anna A\\|B)").lines, expectTrue("unterminated quoted csv is rejected", !MarkdownTable::convertDelimitedToTable("Name,Note\nAnna,\"unterminated").ok); expectTrue("characters after a closing csv quote are rejected", !MarkdownTable::convertDelimitedToTable("Name,Note\nAnna,\"quoted\"junk").ok); expectTrue("whitespace after a closing csv quote is accepted", MarkdownTable::convertDelimitedToTable("Name,Note\nAnna,\"quoted\" ").ok); + expectLines("whitespace before a delimiter after a closing csv quote", MarkdownTable::convertDelimitedToTable("Name,Note,State\nAnna,\"quoted\" ,done").lines, + { + "| Name | Note | State |", + "| ---- | ------ | ----- |", + "| Anna | quoted | done |" + }); expectLines("csv preserves an explicitly empty record", MarkdownTable::convertDelimitedToTable("A,B\n,\nC,D").lines, { "| A | B |",