diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index 86ff6a7..fc22967 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -11,5 +11,5 @@ jobs: name: "Validation" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: gradle/wrapper-validation-action@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index e414393..ab97bbd 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - java: [ '17', '21', '25' ] + java: [ '21', '25', '27' ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: java-version: ${{ matrix.java }} distribution: 'temurin' diff --git a/build.gradle.kts b/build.gradle.kts index 26178db..c42c9c5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,9 @@ buildscript { configurations.classpath { resolutionStrategy { + // JGit 7.x removed GpgObjectSigner which JReleaser still depends on // Fix https://github.com/jreleaser/jreleaser/issues/1643 - force("org.eclipse.jgit:org.eclipse.jgit:5.13.0.202109080827-r") + force("org.eclipse.jgit:org.eclipse.jgit:6.10.0.202406032230-r") } } } @@ -12,7 +13,7 @@ plugins { `maven-publish` signing id("com.diffplug.spotless") version "latest.release" - id("org.jreleaser") version "1.24.0" + id("org.jreleaser") version "1.26.0" } repositories { @@ -20,8 +21,8 @@ repositories { } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 withSourcesJar() withJavadocJar() } @@ -140,7 +141,7 @@ spotless { } tasks.wrapper { - gradleVersion = "9.5.0" + gradleVersion = "9.8.1" distributionType = Wrapper.DistributionType.ALL } diff --git a/configuration-test/build.gradle.kts b/configuration-test/build.gradle.kts index 50208f0..8c0cf20 100644 --- a/configuration-test/build.gradle.kts +++ b/configuration-test/build.gradle.kts @@ -6,8 +6,8 @@ group = "de.cronn" version = "0.0.1-SNAPSHOT" java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 withSourcesJar() withJavadocJar() } diff --git a/data/test/validation/MarkdownTableTest_shouldPadCellsContainingWideCharacters.md b/data/test/validation/MarkdownTableTest_shouldPadCellsContainingWideCharacters.md new file mode 100644 index 0000000..276864e --- /dev/null +++ b/data/test/validation/MarkdownTableTest_shouldPadCellsContainingWideCharacters.md @@ -0,0 +1,5 @@ +| location | correct | +|-------------------------|---------| +| Sanku Maots'ai 三顾冒菜 | ✅ | +| Café | ❌ | +| Café | ❤️ | diff --git a/data/test/validation/MarkdownTableTest_shouldRenderCellsContainingPipesAndLineBreaks.md b/data/test/validation/MarkdownTableTest_shouldRenderCellsContainingPipesAndLineBreaks.md new file mode 100644 index 0000000..f7a5f30 --- /dev/null +++ b/data/test/validation/MarkdownTableTest_shouldRenderCellsContainingPipesAndLineBreaks.md @@ -0,0 +1,4 @@ +| text | +|-----------------------------| +| Maotsai 31.9/kg \| 0.392 kg | +| first line
second line | diff --git a/gradle.lockfile b/gradle.lockfile index a9224f1..cc0c831 100644 --- a/gradle.lockfile +++ b/gradle.lockfile @@ -1,11 +1,11 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.fasterxml.jackson.core:jackson-annotations:2.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.21.3=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.21.3=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.3=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.21.3=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.22=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.22.3=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.22.3=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.22.3=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.22.3=testCompileClasspath,testRuntimeClasspath com.googlecode.java-diff-utils:diffutils:1.3.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.18.3=testCompileClasspath,testRuntimeClasspath org.apiguardian:apiguardian-api:1.1.2=compileClasspath,testCompileClasspath diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index b1b8ef5..9fa7f28 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f193d72..0ce8225 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-all.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index 24c62d5..3185a43 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,13 +19,40 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,24 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/main/java/de/cronn/assertions/validationfile/util/MarkdownTable.java b/src/main/java/de/cronn/assertions/validationfile/util/MarkdownTable.java index f013ec1..825fe90 100644 --- a/src/main/java/de/cronn/assertions/validationfile/util/MarkdownTable.java +++ b/src/main/java/de/cronn/assertions/validationfile/util/MarkdownTable.java @@ -22,7 +22,12 @@ public MarkdownTable(List header) { } public void addCell(Object value) { - currentRow.add(Objects.toString(value)); + currentRow.add(escape(Objects.toString(value))); + } + + // an unescaped pipe or line break would end the cell or the row + private static String escape(String value) { + return value.replace("|", "\\|").replace("\r\n", "
").replace("\n", "
"); } public void addCells(Collection values) { @@ -98,10 +103,11 @@ public String toString() { } private List computeColumnsWidths() { - List widths = header.stream().map(String::length).collect(Collectors.toList()); + List widths = + header.stream().map(MarkdownTable::displayWidth).collect(Collectors.toList()); for (int i = 0; i < header.size(); i++) { for (List row : rows) { - widths.set(i, Math.max(row.get(i).length(), widths.get(i))); + widths.set(i, Math.max(displayWidth(row.get(i)), widths.get(i))); } } return widths; @@ -116,7 +122,50 @@ private void repeat(char ch, int times, StringBuilder sb) { private void pad(String value, int size, StringBuilder sb) { sb.append(' '); sb.append(value); - repeat(' ', size - value.length(), sb); + repeat(' ', size - displayWidth(value), sb); sb.append(' '); } + + /** + * Number of columns the value occupies in a monospace font: East Asian wide characters and emoji + * take two columns, combining marks and format characters (such as the zero width joiner) none. + */ + static int displayWidth(String value) { + int width = 0; + int i = 0; + while (i < value.length()) { + int codePoint = value.codePointAt(i); + i += Character.charCount(codePoint); + if (isZeroWidth(codePoint)) { + continue; + } + boolean emojiPresentation = + i < value.length() && value.charAt(i) == EMOJI_PRESENTATION_SELECTOR; + width += isWide(codePoint) || emojiPresentation ? 2 : 1; + } + return width; + } + + private static final char EMOJI_PRESENTATION_SELECTOR = '\uFE0F'; + + private static boolean isZeroWidth(int codePoint) { + int type = Character.getType(codePoint); + return type == Character.NON_SPACING_MARK + || type == Character.ENCLOSING_MARK + || type == Character.FORMAT; + } + + // approximates East_Asian_Width W and F, which the JDK does not provide + private static boolean isWide(int codePoint) { + if (Character.isEmojiPresentation(codePoint)) { + return true; + } + return switch (Character.UnicodeScript.of(codePoint)) { + case HAN, HIRAGANA, KATAKANA, HANGUL, BOPOMOFO, YI -> true; + default -> + Character.UnicodeBlock.of(codePoint) == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION + || (codePoint >= 0xFF01 && codePoint <= 0xFF60) // fullwidth forms + || (codePoint >= 0xFFE0 && codePoint <= 0xFFE6); + }; + } } diff --git a/src/test/java/de/cronn/assertions/validationfile/util/MarkdownTableTest.java b/src/test/java/de/cronn/assertions/validationfile/util/MarkdownTableTest.java index 08b2cce..7360223 100644 --- a/src/test/java/de/cronn/assertions/validationfile/util/MarkdownTableTest.java +++ b/src/test/java/de/cronn/assertions/validationfile/util/MarkdownTableTest.java @@ -46,4 +46,36 @@ void shouldPadCells() { + "| Countrywide Powdered Coatings Ltd |\n" + "| Craft Textiles Ltd |"); } + + @Test + void shouldPadCellsContainingWideCharacters() { + MarkdownTable markdownTable = new MarkdownTable(List.of("location", "correct")); + markdownTable.addRow("Sanku Maots'ai 三顾冒菜", "✅"); + markdownTable.addRow("Café", "❌"); + markdownTable.addRow("Cafe\u0301", "❤\uFE0F"); + + assertWithFile(markdownTable.toString(), FileExtensions.MD); + } + + @Test + void shouldRenderCellsContainingPipesAndLineBreaks() { + MarkdownTable markdownTable = new MarkdownTable(List.of("text")); + markdownTable.addRow("Maotsai 31.9/kg | 0.392 kg"); + markdownTable.addRow("first line\nsecond line"); + + assertWithFile(markdownTable.toString(), FileExtensions.MD); + } + + @Test + void shouldComputeDisplayWidth() { + assertThat(MarkdownTable.displayWidth("abc")).isEqualTo(3); + assertThat(MarkdownTable.displayWidth("三顾冒菜")).isEqualTo(8); + assertThat(MarkdownTable.displayWidth("AB")).isEqualTo(4); + assertThat(MarkdownTable.displayWidth("한국어")).isEqualTo(6); + assertThat(MarkdownTable.displayWidth("✅❌")).isEqualTo(4); + assertThat(MarkdownTable.displayWidth("😀")).isEqualTo(2); + assertThat(MarkdownTable.displayWidth("✓✗")).isEqualTo(2); + assertThat(MarkdownTable.displayWidth("e\u0301")).isEqualTo(1); + assertThat(MarkdownTable.displayWidth("❤\uFE0F")).isEqualTo(2); + } }