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 extends Object> 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);
+ }
}