From 7b5dedf36618645db3fcbb4b7aa0ab34a3a3e078 Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 01:49:13 +0200 Subject: [PATCH 01/16] feat: Add AsciiDoc formatting --- README.md | 2 + .../asciidoc/AsciidocFormatterConfig.java | 124 ++ .../asciidoc/AsciidocFormatterFunc.java | 806 +++++++++++++ .../asciidoc/AsciidocFormatterStep.java | 28 + .../asciidoc/AsciidocFormatterFuncTest.java | 1050 +++++++++++++++++ plugin-gradle/README.md | 36 + .../gradle/spotless/AsciidocExtension.java | 121 ++ .../gradle/spotless/SpotlessExtension.java | 6 + plugin-maven/README.md | 39 + .../spotless/maven/AbstractSpotlessMojo.java | 6 +- .../spotless/maven/asciidoc/Asciidoc.java | 39 + .../maven/asciidoc/AsciidocFormatting.java | 77 ++ .../resources/asciidoc/asciidocAfter.adoc | 14 + .../resources/asciidoc/asciidocBefore.adoc | 15 + .../asciidoc/AsciidocFormatterStepTest.java | 95 ++ 15 files changed, 2457 insertions(+), 1 deletion(-) create mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterConfig.java create mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java create mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStep.java create mode 100644 lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java create mode 100644 plugin-gradle/src/main/java/com/diffplug/gradle/spotless/AsciidocExtension.java create mode 100644 plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc/Asciidoc.java create mode 100644 plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormatting.java create mode 100644 testlib/src/main/resources/asciidoc/asciidocAfter.adoc create mode 100644 testlib/src/main/resources/asciidoc/asciidocBefore.adoc create mode 100644 testlib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStepTest.java diff --git a/README.md b/README.md index 81f7891891..843e06df6e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ lib('generic.NativeCmdStep') +'{{yes}} | {{yes}} lib('generic.ReplaceRegexStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |', lib('generic.ReplaceStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |', lib('generic.TrimTrailingWhitespaceStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |', +lib('asciidoc.AsciidocFormatterStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |', lib('antlr4.Antlr4FormatterStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |', lib('biome.BiomeStep') +'{{yes}} | {{yes}} | {{no}} | {{no}} |', lib('cpp.ClangFormatStep') +'{{yes}} | {{no}} | {{no}} | {{no}} |', @@ -133,6 +134,7 @@ lib('yaml.JacksonYamlStep') +'{{yes}} | {{yes}} | [`generic.ReplaceRegexStep`](lib/src/main/java/com/diffplug/spotless/generic/ReplaceRegexStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: | | [`generic.ReplaceStep`](lib/src/main/java/com/diffplug/spotless/generic/ReplaceStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: | | [`generic.TrimTrailingWhitespaceStep`](lib/src/main/java/com/diffplug/spotless/generic/TrimTrailingWhitespaceStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: | +| [`asciidoc.AsciidocFormatterStep`](lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: | | [`antlr4.Antlr4FormatterStep`](lib/src/main/java/com/diffplug/spotless/antlr4/Antlr4FormatterStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: | | [`biome.BiomeStep`](lib/src/main/java/com/diffplug/spotless/biome/BiomeStep.java) | :+1: | :+1: | :white_large_square: | :white_large_square: | | [`cpp.ClangFormatStep`](lib/src/main/java/com/diffplug/spotless/cpp/ClangFormatStep.java) | :+1: | :white_large_square: | :white_large_square: | :white_large_square: | diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterConfig.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterConfig.java new file mode 100644 index 0000000000..93577ee2cb --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterConfig.java @@ -0,0 +1,124 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import java.io.Serial; +import java.io.Serializable; + +public class AsciidocFormatterConfig implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private boolean normalizeSetextHeadings = true; + private boolean collapseConsecutiveBlankLines = true; + private boolean oneSentencePerLine = true; + private boolean normalizeBlockDelimiters = true; + private boolean removeTrailingHeaderEqualsSign = true; + private boolean titleCase = false; + private boolean removeTrailingWhitespace = true; + private boolean normalizeListBullets = false; + private boolean normalizeOrderedListMarkers = false; + private boolean ensureHeadingBlankLines = true; + private boolean ensureSourceDelimiters = false; + + public boolean isNormalizeSetextHeadings() { + return normalizeSetextHeadings; + } + + public void setNormalizeSetextHeadings(boolean normalizeSetextHeadings) { + this.normalizeSetextHeadings = normalizeSetextHeadings; + } + + public boolean isCollapseConsecutiveBlankLines() { + return collapseConsecutiveBlankLines; + } + + public void setCollapseConsecutiveBlankLines(boolean collapseConsecutiveBlankLines) { + this.collapseConsecutiveBlankLines = collapseConsecutiveBlankLines; + } + + public boolean isOneSentencePerLine() { + return oneSentencePerLine; + } + + public void setOneSentencePerLine(boolean oneSentencePerLine) { + this.oneSentencePerLine = oneSentencePerLine; + } + + public boolean isNormalizeBlockDelimiters() { + return normalizeBlockDelimiters; + } + + public void setNormalizeBlockDelimiters(boolean normalizeBlockDelimiters) { + this.normalizeBlockDelimiters = normalizeBlockDelimiters; + } + + public boolean isRemoveTrailingHeaderEqualsSign() { + return removeTrailingHeaderEqualsSign; + } + + public void setRemoveTrailingHeaderEqualsSign(boolean removeTrailingHeaderEqualsSign) { + this.removeTrailingHeaderEqualsSign = removeTrailingHeaderEqualsSign; + } + + public boolean isTitleCase() { + return titleCase; + } + + public void setTitleCase(boolean titleCase) { + this.titleCase = titleCase; + } + + public boolean isRemoveTrailingWhitespace() { + return removeTrailingWhitespace; + } + + public void setRemoveTrailingWhitespace(boolean removeTrailingWhitespace) { + this.removeTrailingWhitespace = removeTrailingWhitespace; + } + + public boolean isNormalizeListBullets() { + return normalizeListBullets; + } + + public void setNormalizeListBullets(boolean normalizeListBullets) { + this.normalizeListBullets = normalizeListBullets; + } + + public boolean isNormalizeOrderedListMarkers() { + return normalizeOrderedListMarkers; + } + + public void setNormalizeOrderedListMarkers(boolean normalizeOrderedListMarkers) { + this.normalizeOrderedListMarkers = normalizeOrderedListMarkers; + } + + public boolean isEnsureHeadingBlankLines() { + return ensureHeadingBlankLines; + } + + public void setEnsureHeadingBlankLines(boolean ensureHeadingBlankLines) { + this.ensureHeadingBlankLines = ensureHeadingBlankLines; + } + + public boolean isEnsureSourceDelimiters() { + return ensureSourceDelimiters; + } + + public void setEnsureSourceDelimiters(boolean ensureSourceDelimiters) { + this.ensureSourceDelimiters = ensureSourceDelimiters; + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java new file mode 100644 index 0000000000..f685b18e6d --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -0,0 +1,806 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.diffplug.spotless.FormatterFunc; + +public class AsciidocFormatterFunc implements FormatterFunc { + + // ── constants ───────────────────────────────────────────────────────────── + + private static final Map UNDERLINE_LEVEL = Map.of( + '=', 0, + '-', 1, + '~', 2, + '^', 3, + '+', 4); + + // Standard AsciiDoc block delimiters: ----, ====, ...., ****, ____, ++++, //// + private static final Pattern BLOCK_DELIMITER = Pattern.compile("^(-{4,}|={4,}|\\.{4,}|\\*{4,}|_{4,}|\\+{4,}|/{4,})$"); + + // The same character set, used to detect over-long delimiters (5+) + private static final String BLOCK_DELIMITER_CHARS = "-=.*_+/"; + + // Heading with trailing = signs: == Title == or === Title === + // Captured groups: (1) leading equals, (2) title text (trimmed) + private static final Pattern SYMMETRIC_HEADING = Pattern.compile("^(={1,6})\\s+(.*\\S)\\s+=+\\s*$"); + + // Section heading: = Title or == Title, etc. + // Captured groups: (1) leading equals, (2) title text + private static final Pattern SECTION_HEADING = Pattern.compile("^(={1,6})\\s+(.+)$"); + + // Words lowercased in title case (articles, conjunctions, short prepositions) + private static final Set TITLE_CASE_LOWERCASE = Set.of( + "a", "an", "the", + "and", "but", "or", "nor", "for", "yet", "so", + "at", "by", "in", "of", "on", "to", "up", "as", "off", "out", "per", "via"); + + // Unordered (* or -) and ordered (. or digits) list item markers followed by space or tab + private static final Pattern LIST_ITEM = Pattern.compile("^([*\\-]+ |\\.+ |\\d+\\.[ \\t]).*"); + + // Explicit numbered ordered list item: "1. text", "1.\ttext", "42. text" + private static final Pattern NUMBERED_LIST_ITEM = Pattern.compile("^(\\d+)\\.[ \\t](.*)$"); + + // Any ATX heading, used to normalise whitespace (tab → space) after the = signs + // Captured groups: (1) leading equals, (2) trimmed title text + private static final Pattern ATX_HEADING = Pattern.compile("^(={1,6})\\s+(\\S.*?)\\s*$"); + + // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], etc. + private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%].*"); + + // Known abbreviations that end with a period but do not end a sentence + private static final Set ABBREVIATIONS = Set.of( + "mr", "mrs", "ms", "dr", "prof", "sr", "jr", + "vs", "etc", "approx", "dept", "fig", "no", "vol", + "ch", "sec", "ref", "rev", "st", "mt", "ft", + "ave", "blvd", "rd", "pp", "al", "ed", "eds", + "corp", "inc", "ltd", "llc", + "jan", "feb", "mar", "apr", "jun", "jul", + "aug", "sep", "oct", "nov", "dec"); + + private final AsciidocFormatterConfig config; + + public AsciidocFormatterFunc(AsciidocFormatterConfig config) { + this.config = config; + } + + // ── entry point ─────────────────────────────────────────────────────────── + + @Override + public String apply(String input) throws Exception { + String[] lines = input.split("\n", -1); + + if (config.isRemoveTrailingWhitespace()) { + lines = removeTrailingWhitespace(lines); + } + if (config.isEnsureSourceDelimiters()) { + lines = ensureSourceDelimiters(lines); + } + if (config.isNormalizeSetextHeadings()) { + lines = normalizeSetextHeadings(lines); + } + if (config.isNormalizeBlockDelimiters()) { + lines = normalizeBlockDelimiters(lines); + } + if (config.isRemoveTrailingHeaderEqualsSign()) { + lines = removeTrailingHeaderEqualsSign(lines); + } + if (config.isTitleCase()) { + lines = applyTitleCase(lines); + } + if (config.isNormalizeListBullets()) { + lines = normalizeListBullets(lines); + } + if (config.isNormalizeOrderedListMarkers()) { + lines = normalizeOrderedListMarkers(lines); + } + if (config.isEnsureHeadingBlankLines()) { + lines = ensureHeadingBlankLines(lines); + } + if (config.isOneSentencePerLine()) { + lines = applySentencePerLine(lines); + } + + List result = new ArrayList<>(Arrays.asList(lines)); + if (config.isCollapseConsecutiveBlankLines()) { + result = collapseBlankLines(result); + } + return String.join("\n", result); + } + + // ── normalizeSetextHeadings ─────────────────────────────────────────────── + + private String[] normalizeSetextHeadings(String[] lines) { + List result = new ArrayList<>(lines.length); + String openDelimiterChar = null; + int i = 0; + while (i < lines.length) { + String line = lines[i]; + if (openDelimiterChar != null) { + // Inside a delimited block: pass through until the matching closing delimiter. + result.add(line); + if (isDelimiterOfChar(line, openDelimiterChar)) { + openDelimiterChar = null; + } + i++; + continue; + } + if (isBlockDelimiter(line) && !line.isEmpty()) { + result.add(line); + openDelimiterChar = String.valueOf(line.charAt(0)); + i++; + continue; + } + if (i + 1 < lines.length) { + Integer level = detectSetextUnderline(line, lines[i + 1]); + if (level != null) { + result.add("=".repeat(level + 1) + " " + line); + i += 2; + continue; + } + } + result.add(line); + i++; + } + return result.toArray(new String[0]); + } + + /** + * Returns the heading level if {@code titleCandidate} + {@code underlineLine} + * form a setext-style heading, or {@code null} otherwise. + * + *

Title candidates must be plain prose: lines that begin with structural + * AsciiDoc syntax ({@code =}, {@code [}, {@code //}, {@code .}, {@code :}, + * {@code *}, {@code -}, {@code |}, {@code +}) are never heading titles. + */ + private Integer detectSetextUnderline(String titleCandidate, String underlineLine) { + if (titleCandidate.isEmpty()) { + return null; + } + // Structural AsciiDoc lines are never heading title candidates + char first = titleCandidate.charAt(0); + if (first == '=' || first == '[' || first == '.' || first == ':' + || first == '*' || first == '-' || first == '|' || first == '+' + || titleCandidate.startsWith("//")) { + return null; + } + if (underlineLine.isEmpty()) { + return null; + } + char underlineChar = underlineLine.charAt(0); + Integer level = UNDERLINE_LEVEL.get(underlineChar); + if (level == null) { + return null; + } + for (int j = 1; j < underlineLine.length(); j++) { + if (underlineLine.charAt(j) != underlineChar) { + return null; + } + } + // Underline must be at least as long as the title + if (underlineLine.length() < titleCandidate.length()) { + return null; + } + return level; + } + + // ── normalizeBlockDelimiters ────────────────────────────────────────────── + + /** + * Shortens over-long block delimiter lines to exactly four characters. + * + *

A line like {@code --------} (eight dashes) becomes {@code ----}. + * Lines that are already four characters are left unchanged. Setext + * heading underlines (preceded by a prose title) are also left unchanged. + * + *

A state machine tracks open/close pairs so that only the first + * occurrence of a delimiter char on an unmatched line is subject to the + * setext heuristic; once a block is open, its closing delimiter is + * normalised unconditionally. + */ + private String[] normalizeBlockDelimiters(String[] lines) { + List result = new ArrayList<>(lines.length); + // non-null while inside a block; holds the single delimiter character + String openDelimiterChar = null; + + for (String line : lines) { + if (openDelimiterChar != null) { + // Inside a block: look for the matching closing delimiter. + if (isDelimiterOfChar(line, openDelimiterChar)) { + result.add(openDelimiterChar.repeat(4)); + openDelimiterChar = null; + } else { + result.add(line); + } + } else if (isOverLongBlockDelimiter(line)) { + // Outside a block: decide if this is a setext underline or a block delimiter. + String prev = result.isEmpty() ? null : result.get(result.size() - 1); + boolean isSetextUnderline = prev != null && !prev.isBlank() + && detectSetextUnderline(prev, line) != null; + if (isSetextUnderline) { + result.add(line); + // Do NOT enter block-tracking state; setext underlines are not block openers. + } else { + String delimChar = String.valueOf(line.charAt(0)); + result.add(delimChar.repeat(4)); + openDelimiterChar = delimChar; + } + } else if (isBlockDelimiter(line) && !line.isEmpty()) { + // A minimal (4-char) delimiter: enter block-tracking state. + result.add(line); + openDelimiterChar = String.valueOf(line.charAt(0)); + } else { + result.add(line); + } + } + return result.toArray(new String[0]); + } + + /** True when {@code line} consists entirely of {@code delimChar} repeated four or more times. */ + private static boolean isDelimiterOfChar(String line, String delimChar) { + if (line.length() < 4) { + return false; + } + char c = delimChar.charAt(0); + for (int i = 0; i < line.length(); i++) { + if (line.charAt(i) != c) { + return false; + } + } + return true; + } + + /** True when the line is a block-delimiter character repeated five or more times. */ + private static boolean isOverLongBlockDelimiter(String line) { + if (line.length() <= 4) { + return false; + } + char c = line.charAt(0); + if (BLOCK_DELIMITER_CHARS.indexOf(c) < 0) { + return false; + } + for (int i = 1; i < line.length(); i++) { + if (line.charAt(i) != c) { + return false; + } + } + return true; + } + + // ── removeTrailingHeaderEqualsSign ──────────────────────────────────────── + + /** + * Normalises ATX heading syntax: removes symmetric trailing {@code =} signs and + * collapses any whitespace (including tabs) after the leading {@code =} signs to + * a single space. + * + *

Examples: {@code == Title ==} → {@code == Title}, + * {@code ===\tTitle} → {@code === Title}. + */ + private static String[] removeTrailingHeaderEqualsSign(String[] lines) { + String[] result = new String[lines.length]; + for (int i = 0; i < lines.length; i++) { + Matcher symmetric = SYMMETRIC_HEADING.matcher(lines[i]); + if (symmetric.matches()) { + result[i] = symmetric.group(1) + " " + symmetric.group(2); + continue; + } + // Normalise whitespace (tab → space) in any remaining ATX heading. + Matcher atx = ATX_HEADING.matcher(lines[i]); + result[i] = atx.matches() ? atx.group(1) + " " + atx.group(2) : lines[i]; + } + return result; + } + + // ── collapseConsecutiveBlankLines ───────────────────────────────────────── + + private static List collapseBlankLines(List lines) { + List result = new ArrayList<>(lines.size()); + int consecutiveBlank = 0; + for (String line : lines) { + if (line.isBlank()) { + consecutiveBlank++; + if (consecutiveBlank <= 1) { + result.add(line); + } + } else { + consecutiveBlank = 0; + result.add(line); + } + } + return result; + } + + // ── ensureSourceDelimiters ──────────────────────────────────────────────── + + /** + * Wraps bare {@code [source,...]} and {@code [listing]} blocks that have no + * {@code ----} delimiter with a {@code ----} / {@code ----} pair. + * + *

A block is considered "already delimited" when the line immediately + * following the attribute line is any AsciiDoc block-delimiter line. + * Content is collected until the first blank line or end of file. + */ + private static String[] ensureSourceDelimiters(String[] lines) { + List result = new ArrayList<>(lines.length + 8); + String openDelimiterChar = null; + int i = 0; + while (i < lines.length) { + String line = lines[i]; + + if (openDelimiterChar != null) { + result.add(line); + if (isDelimiterOfChar(line, openDelimiterChar)) { + openDelimiterChar = null; + } + i++; + continue; + } + + if (isBlockDelimiter(line) && !line.isEmpty()) { + result.add(line); + openDelimiterChar = String.valueOf(line.charAt(0)); + i++; + continue; + } + + if (SOURCE_BLOCK_ATTR.matcher(line).matches()) { + result.add(line); + i++; + if (i < lines.length) { + String next = lines[i]; + if (isBlockDelimiter(next) && !next.isEmpty()) { + // Already has a delimiter — enter block state normally + result.add(next); + openDelimiterChar = String.valueOf(next.charAt(0)); + i++; + } else if (!next.isBlank() && !next.startsWith("[")) { + // No delimiter: wrap the following paragraph + result.add("----"); + while (i < lines.length && !lines[i].isBlank()) { + result.add(lines[i]); + i++; + } + result.add("----"); + } + // blank or another attribute line: leave as-is + } + continue; + } + + result.add(line); + i++; + } + return result.toArray(new String[0]); + } + + // ── ensureHeadingBlankLines ─────────────────────────────────────────────── + + /** + * Ensures every ATX section heading is preceded and followed by a blank line. + * Lines inside delimited blocks are not touched. + */ + private static String[] ensureHeadingBlankLines(String[] lines) { + List result = new ArrayList<>(lines.length + 8); + String openDelimiterChar = null; + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + + if (openDelimiterChar != null) { + result.add(line); + if (isDelimiterOfChar(line, openDelimiterChar)) { + openDelimiterChar = null; + } + continue; + } + if (isBlockDelimiter(line) && !line.isEmpty()) { + result.add(line); + openDelimiterChar = String.valueOf(line.charAt(0)); + continue; + } + + if (SECTION_HEADING.matcher(line).matches()) { + // blank line before (skip if first line or previous is already blank) + if (!result.isEmpty() && !result.get(result.size() - 1).isBlank()) { + result.add(""); + } + result.add(line); + // blank line after (skip if last line or next is already blank) + if (i + 1 < lines.length && !lines[i + 1].isBlank()) { + result.add(""); + } + } else { + result.add(line); + } + } + return result.toArray(new String[0]); + } + + // ── removeTrailingWhitespace ────────────────────────────────────────────── + + private static String[] removeTrailingWhitespace(String[] lines) { + String[] result = new String[lines.length]; + for (int i = 0; i < lines.length; i++) { + result[i] = lines[i].stripTrailing(); + } + return result; + } + + // ── normalizeListBullets ────────────────────────────────────────────────── + + /** + * Converts dash-style unordered list items ({@code - item}) to the standard + * AsciiDoc asterisk style ({@code * item}). Lines inside delimited blocks + * are passed through unchanged. + */ + private static String[] normalizeListBullets(String[] lines) { + String[] result = new String[lines.length]; + String openDelimiterChar = null; + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (openDelimiterChar != null) { + result[i] = line; + if (isDelimiterOfChar(line, openDelimiterChar)) { + openDelimiterChar = null; + } + } else if (isBlockDelimiter(line) && !line.isEmpty()) { + result[i] = line; + openDelimiterChar = String.valueOf(line.charAt(0)); + } else if (line.startsWith("- ")) { + result[i] = "* " + line.substring(2); + } else { + result[i] = line; + } + } + return result; + } + + // ── normalizeOrderedListMarkers ─────────────────────────────────────────── + + /** + * Converts explicit-number ordered list items ({@code 1. item}) to the + * AsciiDoc auto-numbered dot style ({@code . item}). Lines inside + * delimited blocks are passed through unchanged. + */ + private static String[] normalizeOrderedListMarkers(String[] lines) { + String[] result = new String[lines.length]; + String openDelimiterChar = null; + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (openDelimiterChar != null) { + result[i] = line; + if (isDelimiterOfChar(line, openDelimiterChar)) { + openDelimiterChar = null; + } + } else if (isBlockDelimiter(line) && !line.isEmpty()) { + result[i] = line; + openDelimiterChar = String.valueOf(line.charAt(0)); + } else { + Matcher m = NUMBERED_LIST_ITEM.matcher(line); + result[i] = m.matches() ? ". " + m.group(2) : line; + } + } + return result; + } + + // ── titleCase ───────────────────────────────────────────────────────────── + + private static String[] applyTitleCase(String[] lines) { + String[] result = new String[lines.length]; + for (int i = 0; i < lines.length; i++) { + result[i] = titleCaseLine(lines[i]); + } + return result; + } + + private static String titleCaseLine(String line) { + // Section heading: = Title, == Title, ... + Matcher m = SECTION_HEADING.matcher(line); + if (m.matches()) { + return m.group(1) + " " + toTitleCase(m.group(2)); + } + // Block title: .Title (single dot, not .. and not dot-space) + if (line.length() > 1 && line.charAt(0) == '.' && line.charAt(1) != '.' && line.charAt(1) != ' ') { + return "." + toTitleCase(line.substring(1)); + } + return line; + } + + private static String toTitleCase(String text) { + String[] words = text.split(" ", -1); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < words.length; i++) { + if (i > 0) { + sb.append(' '); + } + boolean forceCapitalize = (i == 0) || (i == words.length - 1); + sb.append(capitalizeWordForTitle(words[i], forceCapitalize)); + } + return sb.toString(); + } + + private static String capitalizeWordForTitle(String word, boolean forceCapitalize) { + if (word.isEmpty()) { + return word; + } + // Skip words containing AsciiDoc special markup (attributes, code spans, block attrs) + if (word.contains("{") || word.contains("`") || word.contains("[")) { + return word; + } + // Skip AsciiDoc macros (word:target — colon not at end) + int colonIdx = word.indexOf(':'); + if (colonIdx > 0 && colonIdx < word.length() - 1) { + return word; + } + // Find first letter + int firstLetter = -1; + for (int i = 0; i < word.length(); i++) { + if (Character.isLetter(word.charAt(i))) { + firstLetter = i; + break; + } + } + if (firstLetter < 0) { + return word; + } + // Extract only letters from firstLetter onward for lowercase-set membership test + StringBuilder coreBuilder = new StringBuilder(); + for (int i = firstLetter; i < word.length(); i++) { + char c = word.charAt(i); + if (Character.isLetter(c)) { + coreBuilder.append(Character.toLowerCase(c)); + } + } + String core = coreBuilder.toString(); + if (!forceCapitalize && TITLE_CASE_LOWERCASE.contains(core)) { + return word.toLowerCase(Locale.ROOT); + } + // Uppercase first letter, leave the rest unchanged (preserves acronyms like API) + return word.substring(0, firstLetter) + + Character.toUpperCase(word.charAt(firstLetter)) + + word.substring(firstLetter + 1); + } + + // ── oneSentencePerLine ──────────────────────────────────────────────────── + + private String[] applySentencePerLine(String[] lines) { + List result = new ArrayList<>(lines.length); + List paragraphBuffer = new ArrayList<>(); + // non-null while inside a delimited block; holds the opening delimiter char + String openDelimiterChar = null; + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + + // ── inside a delimited block: pass through until matching closing delimiter + if (openDelimiterChar != null) { + result.add(line); + if (isBlockDelimiter(line) && !line.isEmpty() + && String.valueOf(line.charAt(0)).equals(openDelimiterChar)) { + openDelimiterChar = null; + } + continue; + } + + // ── opening delimiter: flush any accumulated paragraph, enter block + if (isBlockDelimiter(line)) { + flushParagraph(paragraphBuffer, result); + result.add(line); + openDelimiterChar = String.valueOf(line.charAt(0)); + continue; + } + + // ── setext heading pair: lookahead to avoid mangling title + underline + if (i + 1 < lines.length && !line.isBlank() && isSetextUnderline(lines[i + 1])) { + flushParagraph(paragraphBuffer, result); + result.add(line); + result.add(lines[i + 1]); + i++; + continue; + } + + // ── blank or structurally special line: flush paragraph, pass through + if (line.isBlank() || isSpecialLine(line)) { + flushParagraph(paragraphBuffer, result); + result.add(line); + continue; + } + + // ── plain paragraph text: accumulate + paragraphBuffer.add(line); + } + + flushParagraph(paragraphBuffer, result); + return result.toArray(new String[0]); + } + + private static void flushParagraph(List buffer, List result) { + if (buffer.isEmpty()) { + return; + } + String joined = String.join(" ", buffer).replaceAll(" {2,}", " ").trim(); + result.addAll(splitIntoSentences(joined)); + buffer.clear(); + } + + private static boolean isBlockDelimiter(String line) { + return BLOCK_DELIMITER.matcher(line).matches(); + } + + /** True when {@code line} consists entirely of a single setext underline character. */ + private static boolean isSetextUnderline(String line) { + if (line.length() < 2) { + return false; + } + char c = line.charAt(0); + if (c != '=' && c != '-' && c != '~' && c != '^' && c != '+') { + return false; + } + for (int i = 1; i < line.length(); i++) { + if (line.charAt(i) != c) { + return false; + } + } + return true; + } + + /** + * Returns true for lines that are structural AsciiDoc syntax and must be + * emitted verbatim rather than accumulated into a paragraph. + */ + private static boolean isSpecialLine(String line) { + if (line.isEmpty()) { + return false; + } + char first = line.charAt(0); + if (first == '=') + return true; // headings: = Title, == Section … + if (first == '[') + return true; // block attributes: [source,java] + if (line.startsWith("//")) + return true; // line or block comments + // attribute entries :attr: value (but not :: description-list markers) + if (first == ':' && line.length() > 1 && line.charAt(1) != ':') + return true; + if (first == '|') + return true; // table cells + if (line.equals("+")) + return true; // list continuation + if (first == ' ' || first == '\t') + return true; // indented literal paragraph + if (LIST_ITEM.matcher(line).matches()) + return true; + if (line.startsWith("<<<")) + return true; // page break + if (line.equals("'''")) + return true; // horizontal rule (thematic break) + // block macros (include::, toc::, image::, …) and description-list terms (term::) + if (line.matches("\\w+::.*")) + return true; + return false; + } + + // ── sentence splitting ──────────────────────────────────────────────────── + + static List splitIntoSentences(String text) { + if (text.isEmpty()) { + return Collections.emptyList(); + } + + List sentences = new ArrayList<>(); + int start = 0; + int i = 0; + + while (i < text.length()) { + char c = text.charAt(i); + + if (c == '.' || c == '!' || c == '?') { + + // Skip ellipsis (two or more consecutive dots) + if (c == '.' && i + 1 < text.length() && text.charAt(i + 1) == '.') { + i++; + while (i < text.length() && text.charAt(i) == '.') { + i++; + } + continue; + } + + // Abbreviations: only relevant for full stops + if (c == '.' && isAbbreviationContext(text, i)) { + i++; + continue; + } + + // Skip optional closing characters after the punctuation mark + int j = i + 1; + while (j < text.length() && isSentenceClosingChar(text.charAt(j))) { + j++; + } + + // End of string — remaining text collected after the loop + if (j >= text.length()) { + i = j; + continue; + } + + // Sentence boundary: whitespace followed by an uppercase letter (or end) + if (Character.isWhitespace(text.charAt(j))) { + int k = j; + while (k < text.length() && Character.isWhitespace(text.charAt(k))) { + k++; + } + if (k >= text.length() || Character.isUpperCase(text.charAt(k))) { + String sentence = text.substring(start, j).trim(); + if (!sentence.isEmpty()) { + sentences.add(sentence); + } + start = k; + i = k; + continue; + } + } + } + + i++; + } + + String remaining = text.substring(start).trim(); + if (!remaining.isEmpty()) { + sentences.add(remaining); + } + return sentences; + } + + private static boolean isAbbreviationContext(String text, int dotPos) { + // Digit before dot: decimal numbers and numbered items such as "section 1.2." + if (dotPos > 0 && Character.isDigit(text.charAt(dotPos - 1))) { + return true; + } + // Extract the alphabetic word immediately before the dot + int wordEnd = dotPos; + int wordStart = wordEnd - 1; + while (wordStart >= 0 && Character.isLetter(text.charAt(wordStart))) { + wordStart--; + } + wordStart++; + if (wordStart >= wordEnd) { + return false; + } + String word = text.substring(wordStart, wordEnd); + // Single lowercase letter covers components of e.g., i.e., a.k.a., etc. + if (word.length() == 1 && Character.isLowerCase(word.charAt(0))) { + return true; + } + return ABBREVIATIONS.contains(word.toLowerCase(Locale.ROOT)); + } + + private static boolean isSentenceClosingChar(char c) { + return c == ')' || c == ']' || c == '"' || c == '\'' + || c == '’' /* right single quotation mark */ + || c == '”' /* right double quotation mark */; + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStep.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStep.java new file mode 100644 index 0000000000..ff4841759d --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStep.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import com.diffplug.spotless.FormatterStep; + +public final class AsciidocFormatterStep { + public static final String NAME = "asciidoc"; + + private AsciidocFormatterStep() {} + + public static FormatterStep create(AsciidocFormatterConfig config) { + return FormatterStep.create(NAME, config, AsciidocFormatterFunc::new); + } +} diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java new file mode 100644 index 0000000000..345dcd3c83 --- /dev/null +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java @@ -0,0 +1,1050 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class AsciidocFormatterFuncTest { + + // ── helpers ─────────────────────────────────────────────────────────────── + + /** Config with only oneSentencePerLine toggled; everything else off. */ + private static AsciidocFormatterFunc func(boolean ospl) { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(ospl); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only normalizeSetextHeadings enabled. */ + private static AsciidocFormatterFunc funcSetext() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(true); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only collapseConsecutiveBlankLines enabled. */ + private static AsciidocFormatterFunc funcCollapse() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(true); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only normalizeBlockDelimiters enabled. */ + private static AsciidocFormatterFunc funcDelimiters() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(true); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only removeTrailingHeaderEqualsSign enabled. */ + private static AsciidocFormatterFunc funcTrailingEquals() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(true); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only titleCase enabled. */ + private static AsciidocFormatterFunc funcTitleCase() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(true); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only removeTrailingWhitespace enabled. */ + private static AsciidocFormatterFunc funcTrailingWhitespace() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(true); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only normalizeListBullets enabled. */ + private static AsciidocFormatterFunc funcListBullets() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(true); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only normalizeOrderedListMarkers enabled. */ + private static AsciidocFormatterFunc funcOrderedList() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(true); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only ensureHeadingBlankLines enabled. */ + private static AsciidocFormatterFunc funcHeadingBlanks() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(true); + cfg.setEnsureSourceDelimiters(false); + return new AsciidocFormatterFunc(cfg); + } + + /** Config with only ensureSourceDelimiters enabled. */ + private static AsciidocFormatterFunc funcSourceDelimiters() { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(true); + return new AsciidocFormatterFunc(cfg); + } + + private static String apply(AsciidocFormatterFunc f, String input) { + try { + return f.apply(input); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + // ── normalizeSetextHeadings ─────────────────────────────────────────────── + + @Test + void convertsLevel0SetextHeading() { + assertThat(apply(funcSetext(), "Document Title\n==============")) + .isEqualTo("= Document Title"); + } + + @Test + void convertsLevel1SetextHeading() { + assertThat(apply(funcSetext(), "Section Title\n-------------")) + .isEqualTo("== Section Title"); + } + + @Test + void convertsLevel2SetextHeading() { + assertThat(apply(funcSetext(), "Subsection Title\n~~~~~~~~~~~~~~~~")) + .isEqualTo("=== Subsection Title"); + } + + @Test + void convertsLevel3SetextHeading() { + assertThat(apply(funcSetext(), "Deep Section\n^^^^^^^^^^^^")) + .isEqualTo("==== Deep Section"); + } + + @Test + void convertsLevel4SetextHeading() { + assertThat(apply(funcSetext(), "Deepest Section\n+++++++++++++++")) + .isEqualTo("===== Deepest Section"); + } + + @Test + void convertsAllSetextLevelsInDocument() { + String input = "Document\n========\n\nSection\n-------\n\nSubsection\n~~~~~~~~~~"; + assertThat(apply(funcSetext(), input)).isEqualTo( + "= Document\n\n== Section\n\n=== Subsection"); + } + + @Test + void doesNotConvertWhenUnderlineTooShort() { + // underline shorter than title → not a setext heading + assertThat(apply(funcSetext(), "Long Title Here\n---")) + .isEqualTo("Long Title Here\n---"); + } + + @Test + void doesNotConvertBlockDelimiterAsHeadingUnderline() { + // ---- is a valid block delimiter length but also could be a setext underline; + // the title "Hi" is shorter than "----" (4), so this is ambiguous — + // the rule requires underline.length >= title.length, so "Hi\n----" IS converted. + // A dedicated listing block "----\ncode\n----" must be left alone because there + // is no title line before the first ----. + assertThat(apply(funcSetext(), "----\ncode line\n----")) + .isEqualTo("----\ncode line\n----"); + } + + @Test + void doesNotConvertLineStartingWithEquals() { + // lines starting with = are already atx headings, not setext title candidates + assertThat(apply(funcSetext(), "== Already Atx\n===============")) + .isEqualTo("== Already Atx\n==============="); + } + + @Test + void doesNotConvertLineStartingWithBracket() { + assertThat(apply(funcSetext(), "[source,java]\n=============")) + .isEqualTo("[source,java]\n============="); + } + + @Test + void doesNotConvertLineStartingWithSlash() { + assertThat(apply(funcSetext(), "// comment\n===========")) + .isEqualTo("// comment\n==========="); + } + + @Test + void doesNotConvertClosingBracketBeforeBlockDelimiter() { + // The lone ] line followed by ---- was falsely detected as a setext heading + // (title + dash underline) because normalizeSetextHeadings lacked block tracking. + String input = "[source, json]\n----\nusers: [\n {\n \"id\": \"abc\"\n }\n]\n----"; + assertThat(apply(funcSetext(), input)).isEqualTo(input); + } + + @Test + void setextNormalizationIsIdempotent() throws Exception { + String input = "My Title\n========\n\nA Section\n---------"; + String once = apply(funcSetext(), input); + String twice = apply(funcSetext(), once); + assertThat(twice).isEqualTo(once); + } + + // ── collapseConsecutiveBlankLines ───────────────────────────────────────── + + @Test + void singleBlankLinePreserved() { + assertThat(apply(funcCollapse(), "A\n\nB")).isEqualTo("A\n\nB"); + } + + @Test + void twoBlankLinesCollapsedToOne() { + assertThat(apply(funcCollapse(), "A\n\n\nB")).isEqualTo("A\n\nB"); + } + + @Test + void threeBlankLinesCollapsedToOne() { + assertThat(apply(funcCollapse(), "A\n\n\n\nB")).isEqualTo("A\n\nB"); + } + + @Test + void noBlankLinesUnchanged() { + assertThat(apply(funcCollapse(), "A\nB\nC")).isEqualTo("A\nB\nC"); + } + + @Test + void multipleGroupsEachCollapsed() { + assertThat(apply(funcCollapse(), "A\n\n\nB\n\n\n\nC")).isEqualTo("A\n\nB\n\nC"); + } + + @Test + void leadingBlankLinesCollapsed() { + assertThat(apply(funcCollapse(), "\n\n\nA")).isEqualTo("\nA"); + } + + @Test + void trailingBlankLinesCollapsed() { + assertThat(apply(funcCollapse(), "A\n\n\n")).isEqualTo("A\n"); + } + + @Test + void collapseIsIdempotent() throws Exception { + String input = "A\n\n\nB\n\n\n\nC"; + String once = apply(funcCollapse(), input); + String twice = apply(funcCollapse(), once); + assertThat(twice).isEqualTo(once); + } + + // ── one sentence per line – basic ───────────────────────────────────────── + + @Test + void splitsTwoSentencesOnOneLine() { + String input = "First sentence. Second sentence."; + assertThat(apply(func(true), input)).isEqualTo( + "First sentence.\nSecond sentence."); + } + + @Test + void splitsExclamationAndQuestion() { + String input = "Watch out! Are you sure? Proceed anyway."; + assertThat(apply(func(true), input)).isEqualTo( + "Watch out!\nAre you sure?\nProceed anyway."); + } + + @Test + void joinsMultiLineParagraphThenSplits() { + String input = "This is a long sentence that\nspans multiple lines. Second sentence."; + assertThat(apply(func(true), input)).isEqualTo( + "This is a long sentence that spans multiple lines.\nSecond sentence."); + } + + @Test + void idempotent() throws Exception { + String input = "First sentence. Second sentence.\nThird sentence."; + AsciidocFormatterFunc f = func(true); + String once = apply(f, input); + String twice = apply(f, once); + assertThat(twice).isEqualTo(once); + } + + // ── abbreviation handling ───────────────────────────────────────────────── + + @Test + void doesNotSplitAfterDrAbbreviation() { + String input = "Consult Dr. Smith before proceeding. Then continue."; + assertThat(apply(func(true), input)).isEqualTo( + "Consult Dr. Smith before proceeding.\nThen continue."); + } + + @Test + void doesNotSplitInsideEgAbbreviation() { + String input = "Use a tool (e.g. Spotless) for formatting. It helps."; + assertThat(apply(func(true), input)).isEqualTo( + "Use a tool (e.g. Spotless) for formatting.\nIt helps."); + } + + @Test + void doesNotSplitDecimalNumber() { + String input = "The value is 3.14 approximately. Use it wisely."; + assertThat(apply(func(true), input)).isEqualTo( + "The value is 3.14 approximately.\nUse it wisely."); + } + + @Test + void doesNotSplitEllipsis() { + String input = "Well... that is interesting. Next point."; + assertThat(apply(func(true), input)).isEqualTo( + "Well... that is interesting.\nNext point."); + } + + // ── structural lines must pass through untouched ───────────────────────── + + @Test + void doesNotTouchHeadings() { + String input = "== Section Title\n\nParagraph text."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void doesNotTouchAttributeEntries() { + String input = ":my-attr: some value\n\nParagraph."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void doesNotTouchBlockAttributes() { + String input = "[source,java]\n----\ncode here\n----\n\nText."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void doesNotTouchListItems() { + String input = "* First item. Still item.\n* Second item."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + // ── content inside delimited blocks must pass through untouched ────────── + + @Test + void doesNotReformatInsideListingBlock() { + String input = "----\nFirst sentence. Second sentence.\n----"; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void doesNotReformatInsideExampleBlock() { + String input = "====\nFirst sentence. Second sentence.\n===="; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + // ── block macros and page breaks must pass through untouched ──────────── + + @Test + void pageBreakIsNotJoinedWithAdjacentMacros() { + // toc::[], <<<, and include:: are structural – they must never be accumulated + // into a paragraph and joined into a single line + String input = "toc::[]\n<<<\ninclude::file.adoc[]"; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void pageBreakBetweenParagraphsPassedThrough() { + String input = "First paragraph.\n<<<\nSecond paragraph."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void includeDirectiveNotJoinedWithParagraph() { + String input = "include::chapter.adoc[]\n\nSome text."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void tocMacroPassedThrough() { + assertThat(apply(func(true), "toc::[]")).isEqualTo("toc::[]"); + } + + @Test + void horizontalRulePassedThrough() { + assertThat(apply(func(true), "Sentence one.\n'''\nSentence two.")) + .isEqualTo("Sentence one.\n'''\nSentence two."); + } + + // ── blank lines separate paragraphs (no cross-paragraph joining) ───────── + + @Test + void blankLineSeparatesParagraphs() { + String input = "Paragraph one sentence one. Sentence two.\n\nParagraph two."; + assertThat(apply(func(true), input)).isEqualTo( + "Paragraph one sentence one.\nSentence two.\n\nParagraph two."); + } + + // ── setext heading lookahead ────────────────────────────────────────────── + + @Test + void doesNotMangleSetextHeading() { + String input = "My Section\n----------\n\nParagraph text."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + // ── normalizeBlockDelimiters ────────────────────────────────────────────── + + @Test + void shortensLongDashDelimiter() { + assertThat(apply(funcDelimiters(), "--------\ncode\n--------")) + .isEqualTo("----\ncode\n----"); + } + + @Test + void shortensLongEqualsDelimiter() { + assertThat(apply(funcDelimiters(), "========\ncontent\n========")) + .isEqualTo("====\ncontent\n===="); + } + + @Test + void shortensLongDotDelimiter() { + assertThat(apply(funcDelimiters(), "........\nliteral\n........")) + .isEqualTo("....\nliteral\n...."); + } + + @Test + void shortensLongStarDelimiter() { + assertThat(apply(funcDelimiters(), "********\nsidebar\n********")) + .isEqualTo("****\nsidebar\n****"); + } + + @Test + void shortensLongUnderscoreDelimiter() { + assertThat(apply(funcDelimiters(), "________\nquote\n________")) + .isEqualTo("____\nquote\n____"); + } + + @Test + void shortensLongPlusDelimiter() { + assertThat(apply(funcDelimiters(), "++++++++\npass\n++++++++")) + .isEqualTo("++++\npass\n++++"); + } + + @Test + void shortensLongSlashDelimiter() { + assertThat(apply(funcDelimiters(), "////////\ncomment\n////////")) + .isEqualTo("////\ncomment\n////"); + } + + @Test + void leavesMinimalDelimiterUnchanged() { + String input = "----\ncode\n----"; + assertThat(apply(funcDelimiters(), input)).isEqualTo(input); + } + + @Test + void doesNotShortenSetextHeadingUnderline() { + // ============== is a setext heading underline (preceded by a title), + // not a block delimiter, so it must not be shortened. + String input = "Document Title\n=============="; + assertThat(apply(funcDelimiters(), input)).isEqualTo(input); + } + + @Test + void doesNotShortenTildeSetextUnderline() { + // ~ is not a block-delimiter character, so ~~~~~~~ is always a setext underline. + String input = "Subsection\n~~~~~~~~~~"; + assertThat(apply(funcDelimiters(), input)).isEqualTo(input); + } + + @Test + void blockDelimiterNormalizationIsIdempotent() throws Exception { + String input = "--------\ncode\n--------\n\n========\nblock\n========"; + String once = apply(funcDelimiters(), input); + String twice = apply(funcDelimiters(), once); + assertThat(twice).isEqualTo(once); + } + + // ── removeTrailingHeaderEqualsSign ──────────────────────────────────────── + + @Test + void removesTrailingEqualsFromH2() { + assertThat(apply(funcTrailingEquals(), "== Section Title ==")) + .isEqualTo("== Section Title"); + } + + @Test + void removesTrailingEqualsFromH3() { + assertThat(apply(funcTrailingEquals(), "=== Subsection ===")) + .isEqualTo("=== Subsection"); + } + + @Test + void removesTrailingEqualsFromH4() { + assertThat(apply(funcTrailingEquals(), "==== Deep ==== Section ====")) + .isEqualTo("==== Deep ==== Section"); + } + + @Test + void removesTrailingEqualsWithTrailingSpaces() { + assertThat(apply(funcTrailingEquals(), "== Title == ")) + .isEqualTo("== Title"); + } + + @Test + void leavesAsymmetricHeadingUnchanged() { + String input = "== Already Asymmetric"; + assertThat(apply(funcTrailingEquals(), input)).isEqualTo(input); + } + + @Test + void leavesNonHeadingLinesUnchanged() { + String input = "Normal paragraph with == signs == inside."; + assertThat(apply(funcTrailingEquals(), input)).isEqualTo(input); + } + + @Test + void removeTrailingEqualsIsIdempotent() throws Exception { + String input = "== Title ==\n=== Sub ==="; + String once = apply(funcTrailingEquals(), input); + String twice = apply(funcTrailingEquals(), once); + assertThat(twice).isEqualTo(once); + } + + // ── splitIntoSentences unit tests ───────────────────────────────────────── + + @Test + void singleSentenceReturnedAsIs() { + AsciidocFormatterFunc f = func(false); + assertThat(f.splitIntoSentences("Just one sentence.")) + .containsExactly("Just one sentence."); + } + + @Test + void lowercaseAfterPeriodIsNotASplit() { + AsciidocFormatterFunc f = func(false); + assertThat(f.splitIntoSentences("lowercase follows. not a new sentence. no split here.")) + .containsExactly("lowercase follows. not a new sentence. no split here."); + } + + // ── titleCase ───────────────────────────────────────────────────────────── + + @Test + void titleCasesLevel1SectionHeading() { + assertThat(apply(funcTitleCase(), "= examples of title case")) + .isEqualTo("= Examples of Title Case"); + } + + @Test + void titleCasesLevel2SectionHeading() { + assertThat(apply(funcTitleCase(), "== the quick brown fox")) + .isEqualTo("== The Quick Brown Fox"); + } + + @Test + void titleCasesDeepSectionHeading() { + assertThat(apply(funcTitleCase(), "==== art of the deal")) + .isEqualTo("==== Art of the Deal"); + } + + @Test + void titleCasesBlockTitle() { + assertThat(apply(funcTitleCase(), ".examples of title case")) + .isEqualTo(".Examples of Title Case"); + } + + @Test + void firstWordAlwaysCapitalizedEvenIfInLowercaseSet() { + // "of" is in the lowercase set but as the first word it must be capitalized + assertThat(apply(funcTitleCase(), "== of mice and men")) + .isEqualTo("== Of Mice and Men"); + } + + @Test + void lastWordAlwaysCapitalized() { + // "the" at the end must be capitalized + assertThat(apply(funcTitleCase(), "== end of the")) + .isEqualTo("== End of The"); + } + + @Test + void articlesLowercasedInMiddle() { + assertThat(apply(funcTitleCase(), "== the cat and the hat")) + .isEqualTo("== The Cat and the Hat"); + } + + @Test + void prepositionLowercasedInMiddle() { + assertThat(apply(funcTitleCase(), "== art of war")) + .isEqualTo("== Art of War"); + } + + @Test + void coordinatingConjunctionLowercased() { + assertThat(apply(funcTitleCase(), "== black or white")) + .isEqualTo("== Black or White"); + } + + @Test + void wordWithAttributeReferenceSkipped() { + // {attr} contains special chars — must be left as-is + assertThat(apply(funcTitleCase(), "== {doctitle} overview")) + .isEqualTo("== {doctitle} Overview"); + } + + @Test + void wordWithCodeSpanSkipped() { + assertThat(apply(funcTitleCase(), "== use `code` here")) + .isEqualTo("== Use `code` Here"); + } + + @Test + void wordWithMacroSkipped() { + // link:target[] is an AsciiDoc macro — skip the whole token + assertThat(apply(funcTitleCase(), "== see link:url[] for details")) + .isEqualTo("== See link:url[] for Details"); + } + + @Test + void regularParagraphLineUntouched() { + String input = "this is just regular paragraph text."; + assertThat(apply(funcTitleCase(), input)).isEqualTo(input); + } + + @Test + void alreadyTitleCasedHeadingUnchanged() { + String input = "== Examples of Title Case"; + assertThat(apply(funcTitleCase(), input)).isEqualTo(input); + } + + @Test + void titleCaseIsIdempotent() throws Exception { + String input = "== examples of title case\n\n.a block title with of and the\n\nParagraph."; + String once = apply(funcTitleCase(), input); + String twice = apply(funcTitleCase(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void dotDotLineNotTreatedAsBlockTitle() { + // ..something is not a block title (starts with ..) + String input = "..not a block title"; + assertThat(apply(funcTitleCase(), input)).isEqualTo(input); + } + + @Test + void dotSpaceLineNotTreatedAsBlockTitle() { + // ". item" starts with dot-space — that's an ordered list item, not a block title + String input = ". list item text"; + assertThat(apply(funcTitleCase(), input)).isEqualTo(input); + } + + // ── removeTrailingWhitespace ────────────────────────────────────────────── + + @Test + void trailingSpacesRemovedFromLine() { + assertThat(apply(funcTrailingWhitespace(), "line with trailing spaces ")) + .isEqualTo("line with trailing spaces"); + } + + @Test + void trailingTabRemovedFromLine() { + assertThat(apply(funcTrailingWhitespace(), "line with tab\t")) + .isEqualTo("line with tab"); + } + + @Test + void lineWithoutTrailingWhitespaceUnchanged() { + String input = "clean line"; + assertThat(apply(funcTrailingWhitespace(), input)).isEqualTo(input); + } + + @Test + void blankLineReducedToEmpty() { + assertThat(apply(funcTrailingWhitespace(), " ")).isEqualTo(""); + } + + @Test + void trailingWhitespaceRemovedFromMultipleLines() { + assertThat(apply(funcTrailingWhitespace(), "first \nsecond\t\nthird ")) + .isEqualTo("first\nsecond\nthird"); + } + + @Test + void removeTrailingWhitespaceIsIdempotent() throws Exception { + String input = "line one \nline two\t"; + String once = apply(funcTrailingWhitespace(), input); + String twice = apply(funcTrailingWhitespace(), once); + assertThat(twice).isEqualTo(once); + } + + // ── normalizeListBullets ────────────────────────────────────────────────── + + @Test + void dashListItemConvertedToAsterisk() { + assertThat(apply(funcListBullets(), "- first item")) + .isEqualTo("* first item"); + } + + @Test + void multipleDashItemsAllConverted() { + assertThat(apply(funcListBullets(), "- one\n- two\n- three")) + .isEqualTo("* one\n* two\n* three"); + } + + @Test + void asteriskListItemUnchanged() { + String input = "* existing asterisk item"; + assertThat(apply(funcListBullets(), input)).isEqualTo(input); + } + + @Test + void nestedAsteriskItemsUnchanged() { + String input = "* level one\n** level two\n*** level three"; + assertThat(apply(funcListBullets(), input)).isEqualTo(input); + } + + @Test + void dashInsideCodeBlockUntouched() { + String input = "----\n- not a list item\n----"; + assertThat(apply(funcListBullets(), input)).isEqualTo(input); + } + + @Test + void blockDelimiterDashesNotConvertedToAsterisk() { + // "----" is a block delimiter, not a list item + String input = "----\ncode\n----"; + assertThat(apply(funcListBullets(), input)).isEqualTo(input); + } + + @Test + void listBulletsNormalizationIsIdempotent() throws Exception { + String input = "- one\n- two"; + String once = apply(funcListBullets(), input); + String twice = apply(funcListBullets(), once); + assertThat(twice).isEqualTo(once); + } + + // ── normalizeOrderedListMarkers ─────────────────────────────────────────── + + @Test + void numberedListItemConvertedToAsciiDocStyle() { + assertThat(apply(funcOrderedList(), "1. First item")) + .isEqualTo(". First item"); + } + + @Test + void largeNumberConvertedToAsciiDocStyle() { + assertThat(apply(funcOrderedList(), "42. Some item")) + .isEqualTo(". Some item"); + } + + @Test + void multipleNumberedItemsAllConverted() { + assertThat(apply(funcOrderedList(), "1. First\n2. Second\n3. Third")) + .isEqualTo(". First\n. Second\n. Third"); + } + + @Test + void asciiDocDotStyleUnchanged() { + String input = ". First\n. Second"; + assertThat(apply(funcOrderedList(), input)).isEqualTo(input); + } + + @Test + void numberedListInsideCodeBlockUntouched() { + String input = "----\n1. not a list item\n----"; + assertThat(apply(funcOrderedList(), input)).isEqualTo(input); + } + + @Test + void decimalNumberNotConvertedToListMarker() { + // "3.14" does not match the "digit(s) dot space" pattern + String input = "Version 3.14 is released."; + assertThat(apply(funcOrderedList(), input)).isEqualTo(input); + } + + @Test + void orderedListNormalizationIsIdempotent() throws Exception { + String input = "1. First\n2. Second\n3. Third"; + String once = apply(funcOrderedList(), input); + String twice = apply(funcOrderedList(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void numberedListWithTabAfterNumberConverted() { + // "1.\titem" must be treated the same as "1. item" + assertThat(apply(funcOrderedList(), "1.\tFirst item")) + .isEqualTo(". First item"); + } + + @Test + void numberedListWithTabNotMangledByOneSentencePerLine() { + // "1.\titem" must be recognised as a list item (special line) so that + // oneSentencePerLine does not join consecutive items into one long line + String input = "1.\tFirst item\n2.\tSecond item\n3.\tThird item"; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + // ── removeTrailingHeaderEqualsSign – heading whitespace normalization ────── + + @Test + void tabAfterHeadingMarkerNormalizedToSpace() { + assertThat(apply(funcTrailingEquals(), "===\tNginx")) + .isEqualTo("=== Nginx"); + } + + @Test + void multipleSpacesAfterHeadingMarkerCollapsed() { + assertThat(apply(funcTrailingEquals(), "== Title")) + .isEqualTo("== Title"); + } + + // ── ensureHeadingBlankLines ─────────────────────────────────────────────── + + @Test + void blankLineAddedAfterHeading() { + assertThat(apply(funcHeadingBlanks(), "== Section\nContent")) + .isEqualTo("== Section\n\nContent"); + } + + @Test + void blankLineAddedBeforeHeading() { + assertThat(apply(funcHeadingBlanks(), "Content\n== Section")) + .isEqualTo("Content\n\n== Section"); + } + + @Test + void blankLinesAddedBothSides() { + assertThat(apply(funcHeadingBlanks(), "Before\n== Section\nAfter")) + .isEqualTo("Before\n\n== Section\n\nAfter"); + } + + @Test + void noDoubleBlankLineWhenAlreadyPresent() { + String input = "Before\n\n== Section\n\nAfter"; + assertThat(apply(funcHeadingBlanks(), input)).isEqualTo(input); + } + + @Test + void noBlankLineBeforeFirstHeading() { + assertThat(apply(funcHeadingBlanks(), "= Title\nContent")) + .isEqualTo("= Title\n\nContent"); + } + + @Test + void consecutiveHeadingsGetBlankLineBetweenThem() { + assertThat(apply(funcHeadingBlanks(), "== Section A\n=== Subsection")) + .isEqualTo("== Section A\n\n=== Subsection"); + } + + @Test + void headingInsideCodeBlockGetsNoBlankLine() { + // The "== heading" inside ---- must not acquire surrounding blank lines + String input = "----\n== not a real heading\ncontent\n----"; + assertThat(apply(funcHeadingBlanks(), input)).isEqualTo(input); + } + + @Test + void ensureHeadingBlankLinesIsIdempotent() throws Exception { + String input = "Intro\n== Section\nBody text\n=== Sub\nMore"; + String once = apply(funcHeadingBlanks(), input); + String twice = apply(funcHeadingBlanks(), once); + assertThat(twice).isEqualTo(once); + } + + // ── ensureSourceDelimiters ──────────────────────────────────────────────── + + @Test + void sourceBlockWithoutDelimiterGetsWrapped() { + String input = "[source,java]\npublic void foo() {}"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source,java]\n----\npublic void foo() {}\n----"); + } + + @Test + void sourceBlockAlreadyDelimitedLeftUnchanged() { + String input = "[source,java]\n----\npublic void foo() {}\n----"; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } + + @Test + void listingBlockWithoutDelimiterGetsWrapped() { + String input = "[listing]\nsome literal text"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[listing]\n----\nsome literal text\n----"); + } + + @Test + void multiLineSourceBlockWrapped() { + String input = "[source,yaml]\nkey: value\nother: data"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source,yaml]\n----\nkey: value\nother: data\n----"); + } + + @Test + void sourceBlockFollowedByBlankLineNotWrapped() { + // blank line after the attribute means no content to wrap + String input = "[source,java]\n\nsome text"; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } + + @Test + void sourceBlockFollowedByAnotherAttributeNotWrapped() { + // next line is another block attribute; leave it alone + String input = "[source,java]\n[%linenums]\n----\ncode\n----"; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } + + @Test + void sourceBlockWithLanguageVariantsWrapped() { + String input = "[source, json]\n{\"key\": \"value\"}"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source, json]\n----\n{\"key\": \"value\"}\n----"); + } + + @Test + void sourceWithPercentOptionWrapped() { + String input = "[source%autofit,java]\npublic class Foo {}"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source%autofit,java]\n----\npublic class Foo {}\n----"); + } + + @Test + void sourceBlockInsideExistingDelimitedBlockLeftAlone() { + // [source] inside ==== must not be touched because we're inside a block + String input = "====\n[source,java]\ncode\n===="; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } + + @Test + void ensureSourceDelimitersIsIdempotent() throws Exception { + String input = "[source,java]\npublic void foo() {}\n\n[source,yaml]\nkey: value"; + String once = apply(funcSourceDelimiters(), input); + String twice = apply(funcSourceDelimiters(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void overLongDelimiterRecognizedAsExistingDelimiter() { + // "--------" (over-long) counts as an existing delimiter — we don't add another ---- + String input = "[source,java]\n--------\ncode\n--------"; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } +} diff --git a/plugin-gradle/README.md b/plugin-gradle/README.md index 339c719668..177068ea74 100644 --- a/plugin-gradle/README.md +++ b/plugin-gradle/README.md @@ -74,6 +74,7 @@ Spotless supports all of Gradle's built-in performance features (incremental bui - [YAML](#yaml) - [Shell](#shell) - [Gherkin](#gherkin) + - [AsciiDoc](#asciidoc) - Multiple languages - [Prettier](#prettier) ([plugins](#prettier-plugins), [npm detection](#npm-detection), [`.npmrc` detection](#npmrc-detection), [caching `npm install` results](#caching-results-of-npm-install)) - [clang-format](#clang-format) @@ -1323,6 +1324,41 @@ spotless { } ``` +## AsciiDoc + +`com.diffplug.gradle.spotless.AsciidocExtension` [code](https://github.com/diffplug/spotless/blob/main/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/AsciidocExtension.java) + +A formatter for AsciiDoc (`.adoc`) files. All options are boolean flags with sensible defaults — enable or disable only what you need. + +```gradle +spotless { + asciidoc { + target '**/*.adoc' // you have to set the target manually + asciidoc() + // Heading style — defaults match the AsciiDoc-recommended ATX (= prefix) style + .normalizeSetextHeadings(true) // convert underline-style (setext) headings to = prefix style (default: true) + .removeTrailingHeaderEqualsSign(true) // remove symmetric trailing = signs: == Title == → == Title (default: true) + .ensureHeadingBlankLines(true) // insert blank lines before and after section headings (default: true) + .titleCase(false) // apply Chicago-style title case to section headings and block titles (default: false) + + // Block structure + .normalizeBlockDelimiters(true) // shorten over-long delimiters: -------- → ---- (default: true) + .ensureSourceDelimiters(false) // wrap bare [source,...] / [listing] blocks with ---- delimiters (default: false) + + // List markers + .normalizeListBullets(false) // convert dash bullets to asterisk: "- item" → "* item" (default: false) + .normalizeOrderedListMarkers(false) // convert numbered markers to dot style: "1. item" → ". item" (default: false) + + // Whitespace + .removeTrailingWhitespace(true) // strip trailing whitespace from every line (default: true) + .collapseConsecutiveBlankLines(true) // collapse multiple consecutive blank lines into one (default: true) + + // Prose + .oneSentencePerLine(true) // reflow paragraph text so each sentence is on its own line (default: true) + } +} +``` + ## CSS `com.diffplug.gradle.spotless.CssExtension` [javadoc](https://javadoc.io/doc/com.diffplug.spotless/spotless-plugin-gradle/8.6.0/com/diffplug/gradle/spotless/CssExtension.html), [code](https://github.com/diffplug/spotless/blob/main/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/CssExtension.java) diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/AsciidocExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/AsciidocExtension.java new file mode 100644 index 0000000000..ff9b688b7a --- /dev/null +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/AsciidocExtension.java @@ -0,0 +1,121 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.gradle.spotless; + +import javax.inject.Inject; + +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.asciidoc.AsciidocFormatterConfig; +import com.diffplug.spotless.asciidoc.AsciidocFormatterStep; + +public class AsciidocExtension extends FormatExtension { + static final String NAME = "asciidoc"; + + @Inject + public AsciidocExtension(SpotlessExtension spotless) { + super(spotless); + } + + @Override + protected void setupTask(SpotlessTask task) { + if (target == null) { + throw noDefaultTargetException(); + } + super.setupTask(task); + } + + public AsciidocConfig asciidoc() { + return new AsciidocConfig(); + } + + public class AsciidocConfig { + private final AsciidocFormatterConfig config = new AsciidocFormatterConfig(); + + AsciidocConfig() { + addStep(createStep()); + } + + public AsciidocConfig normalizeSetextHeadings(boolean value) { + config.setNormalizeSetextHeadings(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig collapseConsecutiveBlankLines(boolean value) { + config.setCollapseConsecutiveBlankLines(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig oneSentencePerLine(boolean value) { + config.setOneSentencePerLine(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig normalizeBlockDelimiters(boolean value) { + config.setNormalizeBlockDelimiters(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig removeTrailingHeaderEqualsSign(boolean value) { + config.setRemoveTrailingHeaderEqualsSign(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig titleCase(boolean value) { + config.setTitleCase(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig removeTrailingWhitespace(boolean value) { + config.setRemoveTrailingWhitespace(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig normalizeListBullets(boolean value) { + config.setNormalizeListBullets(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig normalizeOrderedListMarkers(boolean value) { + config.setNormalizeOrderedListMarkers(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig ensureHeadingBlankLines(boolean value) { + config.setEnsureHeadingBlankLines(value); + replaceStep(createStep()); + return this; + } + + public AsciidocConfig ensureSourceDelimiters(boolean value) { + config.setEnsureSourceDelimiters(value); + replaceStep(createStep()); + return this; + } + + private FormatterStep createStep() { + return AsciidocFormatterStep.create(config); + } + } +} diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java index 0ab99f11ce..c118845237 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java @@ -228,6 +228,12 @@ public void yaml(Action closure) { format(YamlExtension.NAME, YamlExtension.class, closure); } + /** Configures the special AsciiDoc-specific extension. */ + public void asciidoc(Action closure) { + requireNonNull(closure); + format(AsciidocExtension.NAME, AsciidocExtension.class, closure); + } + /** Configures the special Gherkin-specific extension. */ public void gherkin(Action closure) { requireNonNull(closure); diff --git a/plugin-maven/README.md b/plugin-maven/README.md index 741f813441..19f2cd04f1 100644 --- a/plugin-maven/README.md +++ b/plugin-maven/README.md @@ -55,6 +55,7 @@ user@machine repo % mvn spotless:check - [JSON](#json) ([simple](#simple), [gson](#gson), [jackson](#jackson), [Biome](#biome), [jsonPatch](#jsonPatch)) - [YAML](#yaml) - [Gherkin](#gherkin) + - [AsciiDoc](#asciidoc) - [Go](#go) - [RDF](#RDF) - [Protobuf](#protobuf) ([buf](#buf), [clang-format](#clang)) @@ -1238,6 +1239,44 @@ Uses a Gherkin pretty-printer that optionally allows configuring the number of s ``` +## AsciiDoc + +[code](https://github.com/diffplug/spotless/blob/main/plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc/Asciidoc.java). [available steps](https://github.com/diffplug/spotless/tree/main/plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc). + +A formatter for AsciiDoc (`.adoc`) files. All options are boolean flags — set only the ones you want to override. + +```xml + + + + **/*.adoc + + + + false + false + true + false + + + false + false + + + false + false + + + true + true + + + false + + + +``` + ## Go - `com.diffplug.spotless.maven.FormatterFactory.addStepFactory(FormatterStepFactory)` [code](https://github.com/diffplug/spotless/blob/main/plugin-maven/src/main/java/com/diffplug/spotless/maven/go/Go.java) diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java index 437a684061..48d5822664 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java @@ -63,6 +63,7 @@ import com.diffplug.spotless.Provisioner; import com.diffplug.spotless.extra.P2Provisioner; import com.diffplug.spotless.maven.antlr4.Antlr4; +import com.diffplug.spotless.maven.asciidoc.Asciidoc; import com.diffplug.spotless.maven.cpp.Cpp; import com.diffplug.spotless.maven.css.Css; import com.diffplug.spotless.maven.generic.Format; @@ -203,6 +204,9 @@ public abstract class AbstractSpotlessMojo extends AbstractMojo { @Parameter private Yaml yaml; + @Parameter + private Asciidoc asciidoc; + @Parameter private Gherkin gherkin; @@ -453,7 +457,7 @@ private FileLocator getFileLocator() { } private List getFormatterFactories() { - return Stream.concat(formats.stream(), Stream.of(groovy, java, scala, kotlin, cpp, css, typescript, javascript, antlr4, pom, sql, python, markdown, json, shell, yaml, gherkin, go, rdf, protobuf, tableTest, toml)) + return Stream.concat(formats.stream(), Stream.of(groovy, java, scala, kotlin, cpp, css, typescript, javascript, antlr4, pom, sql, python, markdown, json, shell, yaml, asciidoc, gherkin, go, rdf, protobuf, tableTest, toml)) .filter(Objects::nonNull) .map(factory -> factory.init(repositorySystemSession)) .collect(toList()); diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc/Asciidoc.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc/Asciidoc.java new file mode 100644 index 0000000000..451908c6cd --- /dev/null +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc/Asciidoc.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.maven.asciidoc; + +import java.util.Collections; +import java.util.Set; + +import org.apache.maven.project.MavenProject; + +import com.diffplug.spotless.maven.FormatterFactory; + +public class Asciidoc extends FormatterFactory { + @Override + public Set defaultIncludes(MavenProject project) { + return Collections.emptySet(); + } + + @Override + public String licenseHeaderDelimiter() { + return null; + } + + public void addAsciidocFormatting(AsciidocFormatting step) { + addStepFactory(step); + } +} diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormatting.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormatting.java new file mode 100644 index 0000000000..47565c0f13 --- /dev/null +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormatting.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.maven.asciidoc; + +import org.apache.maven.plugins.annotations.Parameter; + +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.asciidoc.AsciidocFormatterConfig; +import com.diffplug.spotless.asciidoc.AsciidocFormatterStep; +import com.diffplug.spotless.maven.FormatterStepConfig; +import com.diffplug.spotless.maven.FormatterStepFactory; + +public class AsciidocFormatting implements FormatterStepFactory { + + @Parameter + private boolean normalizeSetextHeadings = false; + + @Parameter + private boolean collapseConsecutiveBlankLines = true; + + @Parameter + private boolean oneSentencePerLine = false; + + @Parameter + private boolean normalizeBlockDelimiters = false; + + @Parameter + private boolean removeTrailingHeaderEqualsSign = false; + + @Parameter + private boolean titleCase = false; + + @Parameter + private boolean removeTrailingWhitespace = true; + + @Parameter + private boolean normalizeListBullets = false; + + @Parameter + private boolean normalizeOrderedListMarkers = false; + + @Parameter + private boolean ensureHeadingBlankLines = true; + + @Parameter + private boolean ensureSourceDelimiters = false; + + @Override + public FormatterStep newFormatterStep(FormatterStepConfig config) { + AsciidocFormatterConfig asciidocConfig = new AsciidocFormatterConfig(); + asciidocConfig.setNormalizeSetextHeadings(normalizeSetextHeadings); + asciidocConfig.setCollapseConsecutiveBlankLines(collapseConsecutiveBlankLines); + asciidocConfig.setOneSentencePerLine(oneSentencePerLine); + asciidocConfig.setNormalizeBlockDelimiters(normalizeBlockDelimiters); + asciidocConfig.setRemoveTrailingHeaderEqualsSign(removeTrailingHeaderEqualsSign); + asciidocConfig.setTitleCase(titleCase); + asciidocConfig.setRemoveTrailingWhitespace(removeTrailingWhitespace); + asciidocConfig.setNormalizeListBullets(normalizeListBullets); + asciidocConfig.setNormalizeOrderedListMarkers(normalizeOrderedListMarkers); + asciidocConfig.setEnsureHeadingBlankLines(ensureHeadingBlankLines); + asciidocConfig.setEnsureSourceDelimiters(ensureSourceDelimiters); + return AsciidocFormatterStep.create(asciidocConfig); + } +} diff --git a/testlib/src/main/resources/asciidoc/asciidocAfter.adoc b/testlib/src/main/resources/asciidoc/asciidocAfter.adoc new file mode 100644 index 0000000000..23c38caf99 --- /dev/null +++ b/testlib/src/main/resources/asciidoc/asciidocAfter.adoc @@ -0,0 +1,14 @@ += My Document + +Some text that spans multiple lines. +Second sentence here. + +== First Section + +Text before block. + +---- +code here +---- + +Text after block. diff --git a/testlib/src/main/resources/asciidoc/asciidocBefore.adoc b/testlib/src/main/resources/asciidoc/asciidocBefore.adoc new file mode 100644 index 0000000000..659b9b7821 --- /dev/null +++ b/testlib/src/main/resources/asciidoc/asciidocBefore.adoc @@ -0,0 +1,15 @@ +My Document +=========== + +Some text that spans +multiple lines. Second sentence here. + +== First Section == + +Text before block. + +-------- +code here +-------- + +Text after block. diff --git a/testlib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStepTest.java b/testlib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStepTest.java new file mode 100644 index 0000000000..2397fcd2c0 --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStepTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import org.junit.jupiter.api.Test; + +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.SerializableEqualityTester; +import com.diffplug.spotless.StepHarness; + +class AsciidocFormatterStepTest { + + @Test + void behavior() { + StepHarness.forStep(AsciidocFormatterStep.create(new AsciidocFormatterConfig())) + .testResource("asciidoc/asciidocBefore.adoc", "asciidoc/asciidocAfter.adoc"); + } + + @Test + void equality() { + new SerializableEqualityTester() { + AsciidocFormatterConfig config = new AsciidocFormatterConfig(); + + @Override + protected void setupTest(API api) { + // baseline — default config + api.areDifferentThan(); + + // each field change produces a distinct step + config.setNormalizeSetextHeadings(!config.isNormalizeSetextHeadings()); + api.areDifferentThan(); + + config.setCollapseConsecutiveBlankLines(!config.isCollapseConsecutiveBlankLines()); + api.areDifferentThan(); + + config.setOneSentencePerLine(!config.isOneSentencePerLine()); + api.areDifferentThan(); + + config.setNormalizeBlockDelimiters(!config.isNormalizeBlockDelimiters()); + api.areDifferentThan(); + + config.setRemoveTrailingHeaderEqualsSign(!config.isRemoveTrailingHeaderEqualsSign()); + api.areDifferentThan(); + + config.setTitleCase(!config.isTitleCase()); + api.areDifferentThan(); + + config.setRemoveTrailingWhitespace(!config.isRemoveTrailingWhitespace()); + api.areDifferentThan(); + + config.setNormalizeListBullets(!config.isNormalizeListBullets()); + api.areDifferentThan(); + + config.setNormalizeOrderedListMarkers(!config.isNormalizeOrderedListMarkers()); + api.areDifferentThan(); + + config.setEnsureHeadingBlankLines(!config.isEnsureHeadingBlankLines()); + api.areDifferentThan(); + + config.setEnsureSourceDelimiters(!config.isEnsureSourceDelimiters()); + api.areDifferentThan(); + } + + @Override + protected FormatterStep create() { + AsciidocFormatterConfig snapshot = new AsciidocFormatterConfig(); + snapshot.setNormalizeSetextHeadings(config.isNormalizeSetextHeadings()); + snapshot.setCollapseConsecutiveBlankLines(config.isCollapseConsecutiveBlankLines()); + snapshot.setOneSentencePerLine(config.isOneSentencePerLine()); + snapshot.setNormalizeBlockDelimiters(config.isNormalizeBlockDelimiters()); + snapshot.setRemoveTrailingHeaderEqualsSign(config.isRemoveTrailingHeaderEqualsSign()); + snapshot.setTitleCase(config.isTitleCase()); + snapshot.setRemoveTrailingWhitespace(config.isRemoveTrailingWhitespace()); + snapshot.setNormalizeListBullets(config.isNormalizeListBullets()); + snapshot.setNormalizeOrderedListMarkers(config.isNormalizeOrderedListMarkers()); + snapshot.setEnsureHeadingBlankLines(config.isEnsureHeadingBlankLines()); + snapshot.setEnsureSourceDelimiters(config.isEnsureSourceDelimiters()); + return AsciidocFormatterStep.create(snapshot); + } + }.testEquals(); + } +} From b134f9643be5667d7679ed3bf77230026726ab5e Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 01:59:10 +0200 Subject: [PATCH 02/16] Update CHANGES.md for AsciiDoc formatting support --- CHANGES.md | 2 ++ plugin-gradle/CHANGES.md | 2 ++ plugin-maven/CHANGES.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 41d3df2dfb..8ddddbc674 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ This document is intended for Spotless developers. We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Added +- Add `AsciidocFormatterStep` for formatting AsciiDoc (`.adoc`) files. Supports normalizing setext headings, block delimiters, heading whitespace, list markers, trailing whitespace, blank lines around headings, one-sentence-per-line, title case, and auto-adding `----` delimiters to bare source blocks. ([#2955](https://github.com/diffplug/spotless/pull/2955)) ## [4.6.2] - 2026-05-27 ### Fixed diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index a6492a83f4..0408a76a6d 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -3,6 +3,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`). ## [Unreleased] +### Added +- Add `asciidoc { asciidoc() }` format type for formatting AsciiDoc (`.adoc`) files. ([#2955](https://github.com/diffplug/spotless/pull/2955)) ## [8.6.0] - 2026-05-27 ### Added diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index eedfac8c9d..bed903069e 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -3,6 +3,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Added +- Add `` format type for formatting AsciiDoc (`.adoc`) files. ([#2955](https://github.com/diffplug/spotless/pull/2955)) ## [3.6.0] - 2026-05-27 ### Added From 820d03e49364f8445136d673f6b67cae3708ccd1 Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 10:14:38 +0200 Subject: [PATCH 03/16] Remove redundancies --- .../asciidoc/AsciidocFormatterFunc.java | 87 ++++++++----------- 1 file changed, 34 insertions(+), 53 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index f685b18e6d..502c52e898 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -22,6 +22,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.function.UnaryOperator; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -259,12 +260,8 @@ private String[] normalizeBlockDelimiters(String[] lines) { return result.toArray(new String[0]); } - /** True when {@code line} consists entirely of {@code delimChar} repeated four or more times. */ - private static boolean isDelimiterOfChar(String line, String delimChar) { - if (line.length() < 4) { - return false; - } - char c = delimChar.charAt(0); + /** True when every character in {@code line} equals {@code c}. */ + private static boolean isAllSameChar(String line, char c) { for (int i = 0; i < line.length(); i++) { if (line.charAt(i) != c) { return false; @@ -273,21 +270,15 @@ private static boolean isDelimiterOfChar(String line, String delimChar) { return true; } + /** True when {@code line} consists entirely of {@code delimChar} repeated four or more times. */ + private static boolean isDelimiterOfChar(String line, String delimChar) { + return line.length() >= 4 && isAllSameChar(line, delimChar.charAt(0)); + } + /** True when the line is a block-delimiter character repeated five or more times. */ private static boolean isOverLongBlockDelimiter(String line) { - if (line.length() <= 4) { - return false; - } - char c = line.charAt(0); - if (BLOCK_DELIMITER_CHARS.indexOf(c) < 0) { - return false; - } - for (int i = 1; i < line.length(); i++) { - if (line.charAt(i) != c) { - return false; - } - } - return true; + return line.length() > 4 && BLOCK_DELIMITER_CHARS.indexOf(line.charAt(0)) >= 0 + && isAllSameChar(line, line.charAt(0)); } // ── removeTrailingHeaderEqualsSign ──────────────────────────────────────── @@ -450,14 +441,14 @@ private static String[] removeTrailingWhitespace(String[] lines) { return result; } - // ── normalizeListBullets ────────────────────────────────────────────────── + // ── processLinesSkippingBlocks ──────────────────────────────────────────── /** - * Converts dash-style unordered list items ({@code - item}) to the standard - * AsciiDoc asterisk style ({@code * item}). Lines inside delimited blocks - * are passed through unchanged. + * Applies {@code transform} to every line that is outside a delimited block. + * Lines that open or close a block, and all lines between them, are passed + * through unchanged. */ - private static String[] normalizeListBullets(String[] lines) { + private static String[] processLinesSkippingBlocks(String[] lines, UnaryOperator transform) { String[] result = new String[lines.length]; String openDelimiterChar = null; for (int i = 0; i < lines.length; i++) { @@ -470,15 +461,25 @@ private static String[] normalizeListBullets(String[] lines) { } else if (isBlockDelimiter(line) && !line.isEmpty()) { result[i] = line; openDelimiterChar = String.valueOf(line.charAt(0)); - } else if (line.startsWith("- ")) { - result[i] = "* " + line.substring(2); } else { - result[i] = line; + result[i] = transform.apply(line); } } return result; } + // ── normalizeListBullets ────────────────────────────────────────────────── + + /** + * Converts dash-style unordered list items ({@code - item}) to the standard + * AsciiDoc asterisk style ({@code * item}). Lines inside delimited blocks + * are passed through unchanged. + */ + private static String[] normalizeListBullets(String[] lines) { + return processLinesSkippingBlocks(lines, + line -> line.startsWith("- ") ? "* " + line.substring(2) : line); + } + // ── normalizeOrderedListMarkers ─────────────────────────────────────────── /** @@ -487,24 +488,10 @@ private static String[] normalizeListBullets(String[] lines) { * delimited blocks are passed through unchanged. */ private static String[] normalizeOrderedListMarkers(String[] lines) { - String[] result = new String[lines.length]; - String openDelimiterChar = null; - for (int i = 0; i < lines.length; i++) { - String line = lines[i]; - if (openDelimiterChar != null) { - result[i] = line; - if (isDelimiterOfChar(line, openDelimiterChar)) { - openDelimiterChar = null; - } - } else if (isBlockDelimiter(line) && !line.isEmpty()) { - result[i] = line; - openDelimiterChar = String.valueOf(line.charAt(0)); - } else { - Matcher m = NUMBERED_LIST_ITEM.matcher(line); - result[i] = m.matches() ? ". " + m.group(2) : line; - } - } - return result; + return processLinesSkippingBlocks(lines, line -> { + Matcher m = NUMBERED_LIST_ITEM.matcher(line); + return m.matches() ? ". " + m.group(2) : line; + }); } // ── titleCase ───────────────────────────────────────────────────────────── @@ -599,8 +586,7 @@ private String[] applySentencePerLine(String[] lines) { // ── inside a delimited block: pass through until matching closing delimiter if (openDelimiterChar != null) { result.add(line); - if (isBlockDelimiter(line) && !line.isEmpty() - && String.valueOf(line.charAt(0)).equals(openDelimiterChar)) { + if (isDelimiterOfChar(line, openDelimiterChar)) { openDelimiterChar = null; } continue; @@ -660,12 +646,7 @@ private static boolean isSetextUnderline(String line) { if (c != '=' && c != '-' && c != '~' && c != '^' && c != '+') { return false; } - for (int i = 1; i < line.length(); i++) { - if (line.charAt(i) != c) { - return false; - } - } - return true; + return isAllSameChar(line, c); } /** From 3fe24b9ab1f46feee0079883094f27fc0890063c Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 12:29:25 +0200 Subject: [PATCH 04/16] Address code review findings in AsciidocFormatterFunc --- .../asciidoc/AsciidocFormatterFunc.java | 168 +++++++------ .../asciidoc/AsciidocFormatterFuncTest.java | 220 ++++++------------ 2 files changed, 171 insertions(+), 217 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index 502c52e898..5ad1bc8632 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -39,12 +39,25 @@ public class AsciidocFormatterFunc implements FormatterFunc { '^', 3, '+', 4); - // Standard AsciiDoc block delimiters: ----, ====, ...., ****, ____, ++++, //// - private static final Pattern BLOCK_DELIMITER = Pattern.compile("^(-{4,}|={4,}|\\.{4,}|\\*{4,}|_{4,}|\\+{4,}|/{4,})$"); - - // The same character set, used to detect over-long delimiters (5+) + // Single source of truth for block-delimiter characters; BLOCK_DELIMITER is derived from this. + // To add a new delimiter type, append its character here only. private static final String BLOCK_DELIMITER_CHARS = "-=.*_+/"; + // Standard AsciiDoc block delimiters: ----, ====, ...., ****, ____, ++++, //// + // Derived from BLOCK_DELIMITER_CHARS — do not edit this pattern directly. + private static final Pattern BLOCK_DELIMITER; + static { + StringBuilder pat = new StringBuilder("^("); + boolean first = true; + for (char c : BLOCK_DELIMITER_CHARS.toCharArray()) { + if (!first) pat.append('|'); + pat.append(Pattern.quote(String.valueOf(c))).append("{4,}"); + first = false; + } + pat.append(")$"); + BLOCK_DELIMITER = Pattern.compile(pat.toString()); + } + // Heading with trailing = signs: == Title == or === Title === // Captured groups: (1) leading equals, (2) title text (trimmed) private static final Pattern SYMMETRIC_HEADING = Pattern.compile("^(={1,6})\\s+(.*\\S)\\s+=+\\s*$"); @@ -60,17 +73,18 @@ public class AsciidocFormatterFunc implements FormatterFunc { "at", "by", "in", "of", "on", "to", "up", "as", "off", "out", "per", "via"); // Unordered (* or -) and ordered (. or digits) list item markers followed by space or tab - private static final Pattern LIST_ITEM = Pattern.compile("^([*\\-]+ |\\.+ |\\d+\\.[ \\t]).*"); + private static final Pattern LIST_ITEM = Pattern.compile("^(?:[*\\-]+ |\\.+ |\\d+\\.[ \\t]).*"); // Explicit numbered ordered list item: "1. text", "1.\ttext", "42. text" - private static final Pattern NUMBERED_LIST_ITEM = Pattern.compile("^(\\d+)\\.[ \\t](.*)$"); + // Group 1: the text after the marker (the number itself is not captured) + private static final Pattern NUMBERED_LIST_ITEM = Pattern.compile("^\\d+\\.[ \\t](.*)$"); // Any ATX heading, used to normalise whitespace (tab → space) after the = signs // Captured groups: (1) leading equals, (2) trimmed title text private static final Pattern ATX_HEADING = Pattern.compile("^(={1,6})\\s+(\\S.*?)\\s*$"); - // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], etc. - private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%].*"); + // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], [source#id,java], etc. + private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%#].*"); // Known abbreviations that end with a period but do not end a sentence private static final Set ABBREVIATIONS = Set.of( @@ -94,6 +108,11 @@ public AsciidocFormatterFunc(AsciidocFormatterConfig config) { public String apply(String input) throws Exception { String[] lines = input.split("\n", -1); + // Ordering constraints: + // removeTrailingWhitespace before collapseConsecutiveBlankLines + // — whitespace-only lines must be emptied before they can be collapsed. + // normalizeSetextHeadings before ensureHeadingBlankLines + // — setext headings are converted to ATX first so they receive blank-line padding. if (config.isRemoveTrailingWhitespace()) { lines = removeTrailingWhitespace(lines); } @@ -136,22 +155,19 @@ public String apply(String input) throws Exception { private String[] normalizeSetextHeadings(String[] lines) { List result = new ArrayList<>(lines.length); - String openDelimiterChar = null; + BlockTracker bt = new BlockTracker(); int i = 0; while (i < lines.length) { String line = lines[i]; - if (openDelimiterChar != null) { - // Inside a delimited block: pass through until the matching closing delimiter. + if (bt.isOpen()) { result.add(line); - if (isDelimiterOfChar(line, openDelimiterChar)) { - openDelimiterChar = null; - } + bt.tryClose(line); i++; continue; } - if (isBlockDelimiter(line) && !line.isEmpty()) { + if (isBlockDelimiter(line)) { result.add(line); - openDelimiterChar = String.valueOf(line.charAt(0)); + bt.open(line); i++; continue; } @@ -224,18 +240,13 @@ private Integer detectSetextUnderline(String titleCandidate, String underlineLin */ private String[] normalizeBlockDelimiters(String[] lines) { List result = new ArrayList<>(lines.length); - // non-null while inside a block; holds the single delimiter character - String openDelimiterChar = null; + BlockTracker bt = new BlockTracker(); for (String line : lines) { - if (openDelimiterChar != null) { - // Inside a block: look for the matching closing delimiter. - if (isDelimiterOfChar(line, openDelimiterChar)) { - result.add(openDelimiterChar.repeat(4)); - openDelimiterChar = null; - } else { - result.add(line); - } + if (bt.isOpen()) { + // Inside a block: normalize the closing delimiter; pass everything else through. + String closed = bt.tryClose(line); + result.add(closed != null ? closed.repeat(4) : line); } else if (isOverLongBlockDelimiter(line)) { // Outside a block: decide if this is a setext underline or a block delimiter. String prev = result.isEmpty() ? null : result.get(result.size() - 1); @@ -245,14 +256,13 @@ private String[] normalizeBlockDelimiters(String[] lines) { result.add(line); // Do NOT enter block-tracking state; setext underlines are not block openers. } else { - String delimChar = String.valueOf(line.charAt(0)); - result.add(delimChar.repeat(4)); - openDelimiterChar = delimChar; + result.add(String.valueOf(line.charAt(0)).repeat(4)); + bt.open(line); } - } else if (isBlockDelimiter(line) && !line.isEmpty()) { + } else if (isBlockDelimiter(line)) { // A minimal (4-char) delimiter: enter block-tracking state. result.add(line); - openDelimiterChar = String.valueOf(line.charAt(0)); + bt.open(line); } else { result.add(line); } @@ -337,23 +347,21 @@ private static List collapseBlankLines(List lines) { */ private static String[] ensureSourceDelimiters(String[] lines) { List result = new ArrayList<>(lines.length + 8); - String openDelimiterChar = null; + BlockTracker bt = new BlockTracker(); int i = 0; while (i < lines.length) { String line = lines[i]; - if (openDelimiterChar != null) { + if (bt.isOpen()) { result.add(line); - if (isDelimiterOfChar(line, openDelimiterChar)) { - openDelimiterChar = null; - } + bt.tryClose(line); i++; continue; } - if (isBlockDelimiter(line) && !line.isEmpty()) { + if (isBlockDelimiter(line)) { result.add(line); - openDelimiterChar = String.valueOf(line.charAt(0)); + bt.open(line); i++; continue; } @@ -363,10 +371,10 @@ private static String[] ensureSourceDelimiters(String[] lines) { i++; if (i < lines.length) { String next = lines[i]; - if (isBlockDelimiter(next) && !next.isEmpty()) { + if (isBlockDelimiter(next)) { // Already has a delimiter — enter block state normally result.add(next); - openDelimiterChar = String.valueOf(next.charAt(0)); + bt.open(next); i++; } else if (!next.isBlank() && !next.startsWith("[")) { // No delimiter: wrap the following paragraph @@ -396,21 +404,19 @@ private static String[] ensureSourceDelimiters(String[] lines) { */ private static String[] ensureHeadingBlankLines(String[] lines) { List result = new ArrayList<>(lines.length + 8); - String openDelimiterChar = null; + BlockTracker bt = new BlockTracker(); for (int i = 0; i < lines.length; i++) { String line = lines[i]; - if (openDelimiterChar != null) { + if (bt.isOpen()) { result.add(line); - if (isDelimiterOfChar(line, openDelimiterChar)) { - openDelimiterChar = null; - } + bt.tryClose(line); continue; } - if (isBlockDelimiter(line) && !line.isEmpty()) { + if (isBlockDelimiter(line)) { result.add(line); - openDelimiterChar = String.valueOf(line.charAt(0)); + bt.open(line); continue; } @@ -450,17 +456,15 @@ private static String[] removeTrailingWhitespace(String[] lines) { */ private static String[] processLinesSkippingBlocks(String[] lines, UnaryOperator transform) { String[] result = new String[lines.length]; - String openDelimiterChar = null; + BlockTracker bt = new BlockTracker(); for (int i = 0; i < lines.length; i++) { String line = lines[i]; - if (openDelimiterChar != null) { + if (bt.isOpen()) { result[i] = line; - if (isDelimiterOfChar(line, openDelimiterChar)) { - openDelimiterChar = null; - } - } else if (isBlockDelimiter(line) && !line.isEmpty()) { + bt.tryClose(line); + } else if (isBlockDelimiter(line)) { result[i] = line; - openDelimiterChar = String.valueOf(line.charAt(0)); + bt.open(line); } else { result[i] = transform.apply(line); } @@ -490,7 +494,7 @@ private static String[] normalizeListBullets(String[] lines) { private static String[] normalizeOrderedListMarkers(String[] lines) { return processLinesSkippingBlocks(lines, line -> { Matcher m = NUMBERED_LIST_ITEM.matcher(line); - return m.matches() ? ". " + m.group(2) : line; + return m.matches() ? ". " + m.group(1) : line; }); } @@ -518,7 +522,7 @@ private static String titleCaseLine(String line) { } private static String toTitleCase(String text) { - String[] words = text.split(" ", -1); + String[] words = text.split(" +", -1); // " +" avoids empty tokens from consecutive spaces StringBuilder sb = new StringBuilder(); for (int i = 0; i < words.length; i++) { if (i > 0) { @@ -577,18 +581,15 @@ private static String capitalizeWordForTitle(String word, boolean forceCapitaliz private String[] applySentencePerLine(String[] lines) { List result = new ArrayList<>(lines.length); List paragraphBuffer = new ArrayList<>(); - // non-null while inside a delimited block; holds the opening delimiter char - String openDelimiterChar = null; + BlockTracker bt = new BlockTracker(); for (int i = 0; i < lines.length; i++) { String line = lines[i]; // ── inside a delimited block: pass through until matching closing delimiter - if (openDelimiterChar != null) { + if (bt.isOpen()) { result.add(line); - if (isDelimiterOfChar(line, openDelimiterChar)) { - openDelimiterChar = null; - } + bt.tryClose(line); continue; } @@ -596,7 +597,7 @@ private String[] applySentencePerLine(String[] lines) { if (isBlockDelimiter(line)) { flushParagraph(paragraphBuffer, result); result.add(line); - openDelimiterChar = String.valueOf(line.charAt(0)); + bt.open(line); continue; } @@ -628,7 +629,7 @@ private static void flushParagraph(List buffer, List result) { if (buffer.isEmpty()) { return; } - String joined = String.join(" ", buffer).replaceAll(" {2,}", " ").trim(); + String joined = String.join(" ", buffer).replaceAll("\\s+", " ").trim(); result.addAll(splitIntoSentences(joined)); buffer.clear(); } @@ -687,7 +688,7 @@ private static boolean isSpecialLine(String line) { // ── sentence splitting ──────────────────────────────────────────────────── - static List splitIntoSentences(String text) { + private static List splitIntoSentences(String text) { if (text.isEmpty()) { return Collections.emptyList(); } @@ -781,7 +782,40 @@ private static boolean isAbbreviationContext(String text, int dotPos) { private static boolean isSentenceClosingChar(char c) { return c == ')' || c == ']' || c == '"' || c == '\'' - || c == '’' /* right single quotation mark */ - || c == '”' /* right double quotation mark */; + || c == '\u2019' /* right single quotation mark */ + || c == '\u201D' /* right double quotation mark */; + } + + // ── BlockTracker ────────────────────────────────────────────────────────── + + /** + * Tracks the open/close state of a single AsciiDoc delimited block across a + * line scan. All block-aware methods share this class instead of each + * maintaining their own {@code openDelimiterChar} variable. + */ + private static final class BlockTracker { + private String delimChar = null; + + boolean isOpen() { + return delimChar != null; + } + + void open(String line) { + delimChar = String.valueOf(line.charAt(0)); + } + + /** + * Tests whether {@code line} closes the currently open block. + * If so, resets the open state and returns the delimiter character; + * returns {@code null} if the block remains open. + */ + String tryClose(String line) { + if (delimChar != null && isDelimiterOfChar(line, delimChar)) { + String closed = delimChar; + delimChar = null; + return closed; + } + return null; + } } } diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java index 345dcd3c83..328a78ade8 100644 --- a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java @@ -17,18 +17,20 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.function.Consumer; + import org.junit.jupiter.api.Test; class AsciidocFormatterFuncTest { // ── helpers ─────────────────────────────────────────────────────────────── - /** Config with only oneSentencePerLine toggled; everything else off. */ - private static AsciidocFormatterFunc func(boolean ospl) { + /** Returns a formatter with every feature disabled, then applies {@code customizer}. */ + private static AsciidocFormatterFunc funcWith(Consumer customizer) { AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); cfg.setNormalizeSetextHeadings(false); cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(ospl); + cfg.setOneSentencePerLine(false); cfg.setNormalizeBlockDelimiters(false); cfg.setRemoveTrailingHeaderEqualsSign(false); cfg.setTitleCase(false); @@ -37,177 +39,52 @@ private static AsciidocFormatterFunc func(boolean ospl) { cfg.setNormalizeOrderedListMarkers(false); cfg.setEnsureHeadingBlankLines(false); cfg.setEnsureSourceDelimiters(false); + customizer.accept(cfg); return new AsciidocFormatterFunc(cfg); } - /** Config with only normalizeSetextHeadings enabled. */ + private static AsciidocFormatterFunc func(boolean ospl) { + return funcWith(cfg -> cfg.setOneSentencePerLine(ospl)); + } + private static AsciidocFormatterFunc funcSetext() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(true); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(false); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setNormalizeSetextHeadings(true)); } - /** Config with only collapseConsecutiveBlankLines enabled. */ private static AsciidocFormatterFunc funcCollapse() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(true); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(false); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setCollapseConsecutiveBlankLines(true)); } - /** Config with only normalizeBlockDelimiters enabled. */ private static AsciidocFormatterFunc funcDelimiters() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(true); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(false); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setNormalizeBlockDelimiters(true)); } - /** Config with only removeTrailingHeaderEqualsSign enabled. */ private static AsciidocFormatterFunc funcTrailingEquals() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(true); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(false); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setRemoveTrailingHeaderEqualsSign(true)); } - /** Config with only titleCase enabled. */ private static AsciidocFormatterFunc funcTitleCase() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(true); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(false); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setTitleCase(true)); } - /** Config with only removeTrailingWhitespace enabled. */ private static AsciidocFormatterFunc funcTrailingWhitespace() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(true); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(false); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setRemoveTrailingWhitespace(true)); } - /** Config with only normalizeListBullets enabled. */ private static AsciidocFormatterFunc funcListBullets() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(true); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(false); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setNormalizeListBullets(true)); } - /** Config with only normalizeOrderedListMarkers enabled. */ private static AsciidocFormatterFunc funcOrderedList() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(true); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(false); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setNormalizeOrderedListMarkers(true)); } - /** Config with only ensureHeadingBlankLines enabled. */ private static AsciidocFormatterFunc funcHeadingBlanks() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(true); - cfg.setEnsureSourceDelimiters(false); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setEnsureHeadingBlankLines(true)); } - /** Config with only ensureSourceDelimiters enabled. */ private static AsciidocFormatterFunc funcSourceDelimiters() { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(true); - return new AsciidocFormatterFunc(cfg); + return funcWith(cfg -> cfg.setEnsureSourceDelimiters(true)); } private static String apply(AsciidocFormatterFunc f, String input) { @@ -390,7 +267,7 @@ void idempotent() throws Exception { // ── abbreviation handling ───────────────────────────────────────────────── @Test - void doesNotSplitAfterDrAbbreviation() { + void drAbbreviationIsNotASentenceBoundary() { String input = "Consult Dr. Smith before proceeding. Then continue."; assertThat(apply(func(true), input)).isEqualTo( "Consult Dr. Smith before proceeding.\nThen continue."); @@ -630,16 +507,13 @@ void removeTrailingEqualsIsIdempotent() throws Exception { @Test void singleSentenceReturnedAsIs() { - AsciidocFormatterFunc f = func(false); - assertThat(f.splitIntoSentences("Just one sentence.")) - .containsExactly("Just one sentence."); + assertThat(apply(func(true), "Just one sentence.")).isEqualTo("Just one sentence."); } @Test void lowercaseAfterPeriodIsNotASplit() { - AsciidocFormatterFunc f = func(false); - assertThat(f.splitIntoSentences("lowercase follows. not a new sentence. no split here.")) - .containsExactly("lowercase follows. not a new sentence. no split here."); + assertThat(apply(func(true), "lowercase follows. not a new sentence. no split here.")) + .isEqualTo("lowercase follows. not a new sentence. no split here."); } // ── titleCase ───────────────────────────────────────────────────────────── @@ -1047,4 +921,50 @@ void overLongDelimiterRecognizedAsExistingDelimiter() { String input = "[source,java]\n--------\ncode\n--------"; assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); } + + @Test + void sourceBlockWithIdShorthandGetsWrapped() { + // [source#id,lang] uses AsciiDoc shorthand — must be recognized and wrapped + String input = "[source#intro,java]\npublic void foo() {}"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source#intro,java]\n----\npublic void foo() {}\n----"); + } + + // ── combined transformations ────────────────────────────────────────────── + + @Test + void setextNormalizationThenHeadingBlankLinesThenTitleCase() { + // Exercises the three ordering-dependent transformations in sequence: + // setext → ATX (normalizeSetextHeadings), then blank-line padding + // (ensureHeadingBlankLines), then title-casing (titleCase). + AsciidocFormatterFunc f = funcWith(cfg -> { + cfg.setNormalizeSetextHeadings(true); + cfg.setEnsureHeadingBlankLines(true); + cfg.setTitleCase(true); + }); + String input = "some text\nmy cool section\n---------------\nsome body"; + assertThat(apply(f, input)) + .isEqualTo("some text\n\n== My Cool Section\n\nsome body"); + } + + // ── CRLF input normalization ────────────────────────────────────────────── + + @Test + void crlfLineEndingsNormalizedToLf() { + // removeTrailingWhitespace strips \r, and join("\n") produces LF-only output + assertThat(apply(funcTrailingWhitespace(), "line one\r\nline two\r\n")) + .isEqualTo("line one\nline two\n"); + } + + @Test + void crlfHeadingRecognizedAfterTrailingWhitespaceRemoval() { + // Without removeTrailingWhitespace the \r ends up in the heading text; + // verify that the combination produces correct output. + AsciidocFormatterFunc f = funcWith(cfg -> { + cfg.setRemoveTrailingWhitespace(true); + cfg.setRemoveTrailingHeaderEqualsSign(true); + }); + assertThat(apply(f, "== Title\r\n\r\nBody.\r\n")) + .isEqualTo("== Title\n\nBody.\n"); + } } From 23c886d5c76a5bcad8addf21b97e3fa7b4dd6b90 Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 13:04:59 +0200 Subject: [PATCH 05/16] Address more code review findings in AsciidocFormatterFunc --- .../asciidoc/AsciidocFormatterFunc.java | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index 5ad1bc8632..a19b1a70c3 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -86,6 +86,10 @@ public class AsciidocFormatterFunc implements FormatterFunc { // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], [source#id,java], etc. private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%#].*"); + // Block macros (include::, toc::, image::, …) and description-list terms (term::). + // Used in isSpecialLine — pre-compiled to avoid allocating a new Pattern on every call. + private static final Pattern BLOCK_MACRO = Pattern.compile("\\w+::.*"); + // Known abbreviations that end with a period but do not end a sentence private static final Set ABBREVIATIONS = Set.of( "mr", "mrs", "ms", "dr", "prof", "sr", "jr", @@ -94,7 +98,9 @@ public class AsciidocFormatterFunc implements FormatterFunc { "ave", "blvd", "rd", "pp", "al", "ed", "eds", "corp", "inc", "ltd", "llc", "jan", "feb", "mar", "apr", "jun", "jul", - "aug", "sep", "oct", "nov", "dec"); + "aug", "sep", "sept", "oct", "nov", "dec", + // German abbreviations + "bspw", "bzw", "bzgl", "ca", "evtl", "exkl", "inkl", "sog"); private final AsciidocFormatterConfig config; @@ -106,6 +112,10 @@ public AsciidocFormatterFunc(AsciidocFormatterConfig config) { @Override public String apply(String input) throws Exception { + // Normalize line endings so every downstream method sees only \n. + // Without this, CRLF input leaves \r on each line, which breaks regex matches + // (e.g. detectSetextUnderline's length check) and embeds \r in heading output. + input = input.replace("\r\n", "\n").replace("\r", "\n"); String[] lines = input.split("\n", -1); // Ordering constraints: @@ -501,11 +511,7 @@ private static String[] normalizeOrderedListMarkers(String[] lines) { // ── titleCase ───────────────────────────────────────────────────────────── private static String[] applyTitleCase(String[] lines) { - String[] result = new String[lines.length]; - for (int i = 0; i < lines.length; i++) { - result[i] = titleCaseLine(lines[i]); - } - return result; + return processLinesSkippingBlocks(lines, AsciidocFormatterFunc::titleCaseLine); } private static String titleCaseLine(String line) { @@ -601,8 +607,11 @@ private String[] applySentencePerLine(String[] lines) { continue; } - // ── setext heading pair: lookahead to avoid mangling title + underline - if (i + 1 < lines.length && !line.isBlank() && isSetextUnderline(lines[i + 1])) { + // ── setext heading pair: lookahead to avoid mangling title + underline. + // Use detectSetextUnderline (same logic as normalizeSetextHeadings) so that + // structural lines like [source,java] or short dash-lines like -- are not + // mistakenly treated as heading pairs. + if (i + 1 < lines.length && detectSetextUnderline(line, lines[i + 1]) != null) { flushParagraph(paragraphBuffer, result); result.add(line); result.add(lines[i + 1]); @@ -638,19 +647,7 @@ private static boolean isBlockDelimiter(String line) { return BLOCK_DELIMITER.matcher(line).matches(); } - /** True when {@code line} consists entirely of a single setext underline character. */ - private static boolean isSetextUnderline(String line) { - if (line.length() < 2) { - return false; - } - char c = line.charAt(0); - if (c != '=' && c != '-' && c != '~' && c != '^' && c != '+') { - return false; - } - return isAllSameChar(line, c); - } - - /** +/** * Returns true for lines that are structural AsciiDoc syntax and must be * emitted verbatim rather than accumulated into a paragraph. */ @@ -681,7 +678,7 @@ private static boolean isSpecialLine(String line) { if (line.equals("'''")) return true; // horizontal rule (thematic break) // block macros (include::, toc::, image::, …) and description-list terms (term::) - if (line.matches("\\w+::.*")) + if (BLOCK_MACRO.matcher(line).matches()) return true; return false; } @@ -735,7 +732,7 @@ private static List splitIntoSentences(String text) { while (k < text.length() && Character.isWhitespace(text.charAt(k))) { k++; } - if (k >= text.length() || Character.isUpperCase(text.charAt(k))) { + if (k >= text.length() || Character.isUpperCase(text.charAt(k)) || Character.isDigit(text.charAt(k))) { String sentence = text.substring(start, j).trim(); if (!sentence.isEmpty()) { sentences.add(sentence); From 1d3efc25c5e6304bbab1e6f934fc03feac0ecf52 Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 14:04:29 +0200 Subject: [PATCH 06/16] Improve performance of AsciidocFormatterFunc --- .../asciidoc/AsciidocFormatterFunc.java | 177 +++++++++++------- 1 file changed, 105 insertions(+), 72 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index a19b1a70c3..a101680130 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -20,7 +20,6 @@ import java.util.Collections; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Set; import java.util.function.UnaryOperator; import java.util.regex.Matcher; @@ -32,32 +31,10 @@ public class AsciidocFormatterFunc implements FormatterFunc { // ── constants ───────────────────────────────────────────────────────────── - private static final Map UNDERLINE_LEVEL = Map.of( - '=', 0, - '-', 1, - '~', 2, - '^', 3, - '+', 4); - - // Single source of truth for block-delimiter characters; BLOCK_DELIMITER is derived from this. + // Single source of truth for block-delimiter characters; isBlockDelimiter is derived from this. // To add a new delimiter type, append its character here only. private static final String BLOCK_DELIMITER_CHARS = "-=.*_+/"; - // Standard AsciiDoc block delimiters: ----, ====, ...., ****, ____, ++++, //// - // Derived from BLOCK_DELIMITER_CHARS — do not edit this pattern directly. - private static final Pattern BLOCK_DELIMITER; - static { - StringBuilder pat = new StringBuilder("^("); - boolean first = true; - for (char c : BLOCK_DELIMITER_CHARS.toCharArray()) { - if (!first) pat.append('|'); - pat.append(Pattern.quote(String.valueOf(c))).append("{4,}"); - first = false; - } - pat.append(")$"); - BLOCK_DELIMITER = Pattern.compile(pat.toString()); - } - // Heading with trailing = signs: == Title == or === Title === // Captured groups: (1) leading equals, (2) title text (trimmed) private static final Pattern SYMMETRIC_HEADING = Pattern.compile("^(={1,6})\\s+(.*\\S)\\s+=+\\s*$"); @@ -72,13 +49,6 @@ public class AsciidocFormatterFunc implements FormatterFunc { "and", "but", "or", "nor", "for", "yet", "so", "at", "by", "in", "of", "on", "to", "up", "as", "off", "out", "per", "via"); - // Unordered (* or -) and ordered (. or digits) list item markers followed by space or tab - private static final Pattern LIST_ITEM = Pattern.compile("^(?:[*\\-]+ |\\.+ |\\d+\\.[ \\t]).*"); - - // Explicit numbered ordered list item: "1. text", "1.\ttext", "42. text" - // Group 1: the text after the marker (the number itself is not captured) - private static final Pattern NUMBERED_LIST_ITEM = Pattern.compile("^\\d+\\.[ \\t](.*)$"); - // Any ATX heading, used to normalise whitespace (tab → space) after the = signs // Captured groups: (1) leading equals, (2) trimmed title text private static final Pattern ATX_HEADING = Pattern.compile("^(={1,6})\\s+(\\S.*?)\\s*$"); @@ -86,9 +56,11 @@ public class AsciidocFormatterFunc implements FormatterFunc { // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], [source#id,java], etc. private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%#].*"); - // Block macros (include::, toc::, image::, …) and description-list terms (term::). - // Used in isSpecialLine — pre-compiled to avoid allocating a new Pattern on every call. - private static final Pattern BLOCK_MACRO = Pattern.compile("\\w+::.*"); + // ATX heading prefixes for setext→ATX conversion: ATX_PREFIX[n] = "=".repeat(n+1) + " " + private static final String[] ATX_PREFIX = {"= ", "== ", "=== ", "==== ", "===== ", "====== "}; + + // Pre-compiled whitespace-run pattern used to collapse internal whitespace in flushParagraph. + private static final Pattern MULTI_WHITESPACE = Pattern.compile("\\s+"); // Known abbreviations that end with a period but do not end a sentence private static final Set ABBREVIATIONS = Set.of( @@ -184,7 +156,7 @@ private String[] normalizeSetextHeadings(String[] lines) { if (i + 1 < lines.length) { Integer level = detectSetextUnderline(line, lines[i + 1]); if (level != null) { - result.add("=".repeat(level + 1) + " " + line); + result.add(ATX_PREFIX[level] + line); i += 2; continue; } @@ -218,8 +190,24 @@ private Integer detectSetextUnderline(String titleCandidate, String underlineLin return null; } char underlineChar = underlineLine.charAt(0); - Integer level = UNDERLINE_LEVEL.get(underlineChar); - if (level == null) { + int level; + switch (underlineChar) { + case '=': + level = 0; + break; + case '-': + level = 1; + break; + case '~': + level = 2; + break; + case '^': + level = 3; + break; + case '+': + level = 4; + break; + default: return null; } for (int j = 1; j < underlineLine.length(); j++) { @@ -290,15 +278,9 @@ private static boolean isAllSameChar(String line, char c) { return true; } - /** True when {@code line} consists entirely of {@code delimChar} repeated four or more times. */ - private static boolean isDelimiterOfChar(String line, String delimChar) { - return line.length() >= 4 && isAllSameChar(line, delimChar.charAt(0)); - } - /** True when the line is a block-delimiter character repeated five or more times. */ private static boolean isOverLongBlockDelimiter(String line) { - return line.length() > 4 && BLOCK_DELIMITER_CHARS.indexOf(line.charAt(0)) >= 0 - && isAllSameChar(line, line.charAt(0)); + return line.length() > 4 && isBlockDelimiter(line); } // ── removeTrailingHeaderEqualsSign ──────────────────────────────────────── @@ -503,8 +485,17 @@ private static String[] normalizeListBullets(String[] lines) { */ private static String[] normalizeOrderedListMarkers(String[] lines) { return processLinesSkippingBlocks(lines, line -> { - Matcher m = NUMBERED_LIST_ITEM.matcher(line); - return m.matches() ? ". " + m.group(1) : line; + if (line.isEmpty() || line.charAt(0) < '0' || line.charAt(0) > '9') + return line; + int i = 1; + while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') + i++; + if (i + 1 >= line.length() || line.charAt(i) != '.') + return line; + char sep = line.charAt(i + 1); + if (sep != ' ' && sep != '\t') + return line; + return ". " + line.substring(i + 2); }); } @@ -638,49 +629,91 @@ private static void flushParagraph(List buffer, List result) { if (buffer.isEmpty()) { return; } - String joined = String.join(" ", buffer).replaceAll("\\s+", " ").trim(); + String joined = MULTI_WHITESPACE.matcher(String.join(" ", buffer)).replaceAll(" ").trim(); result.addAll(splitIntoSentences(joined)); buffer.clear(); } private static boolean isBlockDelimiter(String line) { - return BLOCK_DELIMITER.matcher(line).matches(); + int len = line.length(); + if (len < 4) + return false; + char c = line.charAt(0); + if (BLOCK_DELIMITER_CHARS.indexOf(c) < 0) + return false; + for (int i = 1; i < len; i++) { + if (line.charAt(i) != c) + return false; + } + return true; } -/** - * Returns true for lines that are structural AsciiDoc syntax and must be - * emitted verbatim rather than accumulated into a paragraph. - */ + /** + * Returns true for lines that are structural AsciiDoc syntax and must be + * emitted verbatim rather than accumulated into a paragraph. + */ private static boolean isSpecialLine(String line) { - if (line.isEmpty()) { + if (line.isEmpty()) return false; - } char first = line.charAt(0); if (first == '=') - return true; // headings: = Title, == Section … + return true; // headings: = Title, == Section … if (first == '[') - return true; // block attributes: [source,java] + return true; // block attributes: [source,java] if (line.startsWith("//")) - return true; // line or block comments + return true; // line or block comments // attribute entries :attr: value (but not :: description-list markers) if (first == ':' && line.length() > 1 && line.charAt(1) != ':') return true; if (first == '|') - return true; // table cells + return true; // table cells if (line.equals("+")) - return true; // list continuation + return true; // list continuation if (first == ' ' || first == '\t') return true; // indented literal paragraph - if (LIST_ITEM.matcher(line).matches()) - return true; + // unordered list items: * item, ** item, - item, -- item, … + if (first == '*' || first == '-') { + int i = 1; + while (i < line.length() && line.charAt(i) == first) + i++; + return i < line.length() && line.charAt(i) == ' '; + } + // ordered list items (AsciiDoc auto-number): . item, .. item, … + if (first == '.') { + int i = 1; + while (i < line.length() && line.charAt(i) == '.') + i++; + return i < line.length() && line.charAt(i) == ' '; + } + // explicit numbered list items: 1. item, 42.\titem + if (first >= '0' && first <= '9') { + int i = 1; + while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') + i++; + return i + 1 < line.length() && line.charAt(i) == '.' + && (line.charAt(i + 1) == ' ' || line.charAt(i + 1) == '\t'); + } if (line.startsWith("<<<")) - return true; // page break + return true; // page break if (line.equals("'''")) - return true; // horizontal rule (thematic break) + return true; // horizontal rule (thematic break) // block macros (include::, toc::, image::, …) and description-list terms (term::) - if (BLOCK_MACRO.matcher(line).matches()) - return true; - return false; + return isBlockMacroOrTerm(line); + } + + /** True when {@code line} is a block-macro call or description-list term ({@code word::…}). */ + private static boolean isBlockMacroOrTerm(String line) { + int len = line.length(); + int i = 0; + while (i < len) { + char c = line.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || (c >= '0' && c <= '9')) { + i++; + } else { + break; + } + } + return i > 0 && i + 1 < len && line.charAt(i) == ':' && line.charAt(i + 1) == ':'; } // ── sentence splitting ──────────────────────────────────────────────────── @@ -791,14 +824,14 @@ private static boolean isSentenceClosingChar(char c) { * maintaining their own {@code openDelimiterChar} variable. */ private static final class BlockTracker { - private String delimChar = null; + private char delimChar = '\0'; boolean isOpen() { - return delimChar != null; + return delimChar != '\0'; } void open(String line) { - delimChar = String.valueOf(line.charAt(0)); + delimChar = line.charAt(0); } /** @@ -807,9 +840,9 @@ void open(String line) { * returns {@code null} if the block remains open. */ String tryClose(String line) { - if (delimChar != null && isDelimiterOfChar(line, delimChar)) { - String closed = delimChar; - delimChar = null; + if (delimChar != '\0' && line.length() >= 4 && isAllSameChar(line, delimChar)) { + String closed = String.valueOf(delimChar); + delimChar = '\0'; return closed; } return null; From 5569c9fe70f9265003a1b70fa4c352ef290c977c Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 14:26:09 +0200 Subject: [PATCH 07/16] Reduce memory consumption of AsciidocFormatterFunc --- .../asciidoc/AsciidocFormatterFunc.java | 430 ++++++------------ .../asciidoc/AsciidocFormatterFuncTest.java | 44 -- 2 files changed, 145 insertions(+), 329 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index a101680130..f0c1bca63e 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -21,15 +21,15 @@ import java.util.List; import java.util.Locale; import java.util.Set; -import java.util.function.UnaryOperator; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.diffplug.spotless.FormatterFunc; -public class AsciidocFormatterFunc implements FormatterFunc { +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; - // ── constants ───────────────────────────────────────────────────────────── +public class AsciidocFormatterFunc implements FormatterFunc { // Single source of truth for block-delimiter characters; isBlockDelimiter is derived from this. // To add a new delimiter type, append its character here only. @@ -49,14 +49,14 @@ public class AsciidocFormatterFunc implements FormatterFunc { "and", "but", "or", "nor", "for", "yet", "so", "at", "by", "in", "of", "on", "to", "up", "as", "off", "out", "per", "via"); - // Any ATX heading, used to normalise whitespace (tab → space) after the = signs + // Any ATX heading, used to normalise whitespace (tab -> space) after the = signs // Captured groups: (1) leading equals, (2) trimmed title text private static final Pattern ATX_HEADING = Pattern.compile("^(={1,6})\\s+(\\S.*?)\\s*$"); // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], [source#id,java], etc. private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%#].*"); - // ATX heading prefixes for setext→ATX conversion: ATX_PREFIX[n] = "=".repeat(n+1) + " " + // ATX heading prefixes for setext -> ATX conversion: ATX_PREFIX[n] = "=".repeat(n+1) + " " private static final String[] ATX_PREFIX = {"= ", "== ", "=== ", "==== ", "===== ", "====== "}; // Pre-compiled whitespace-run pattern used to collapse internal whitespace in flushParagraph. @@ -80,106 +80,96 @@ public AsciidocFormatterFunc(AsciidocFormatterConfig config) { this.config = config; } - // ── entry point ─────────────────────────────────────────────────────────── - - @Override - public String apply(String input) throws Exception { - // Normalize line endings so every downstream method sees only \n. - // Without this, CRLF input leaves \r on each line, which breaks regex matches - // (e.g. detectSetextUnderline's length check) and embeds \r in heading output. - input = input.replace("\r\n", "\n").replace("\r", "\n"); - String[] lines = input.split("\n", -1); + @NonNull @Override + public String apply(@NonNull String input) throws Exception { + // Use \R to match any line break (LF, CRLF, CR) and avoid multiple replacements + List lines = new ArrayList<>(Arrays.asList(Pattern.compile("\\R").split(input, -1))); // Ordering constraints: // removeTrailingWhitespace before collapseConsecutiveBlankLines - // — whitespace-only lines must be emptied before they can be collapsed. + // - whitespace-only lines must be emptied before they can be collapsed. // normalizeSetextHeadings before ensureHeadingBlankLines - // — setext headings are converted to ATX first so they receive blank-line padding. + // - setext headings are converted to ATX first so they receive blank-line padding. if (config.isRemoveTrailingWhitespace()) { - lines = removeTrailingWhitespace(lines); + removeTrailingWhitespace(lines); } if (config.isEnsureSourceDelimiters()) { - lines = ensureSourceDelimiters(lines); + ensureSourceDelimiters(lines); } if (config.isNormalizeSetextHeadings()) { - lines = normalizeSetextHeadings(lines); + normalizeSetextHeadings(lines); } if (config.isNormalizeBlockDelimiters()) { - lines = normalizeBlockDelimiters(lines); + normalizeBlockDelimiters(lines); } if (config.isRemoveTrailingHeaderEqualsSign()) { - lines = removeTrailingHeaderEqualsSign(lines); - } - if (config.isTitleCase()) { - lines = applyTitleCase(lines); - } - if (config.isNormalizeListBullets()) { - lines = normalizeListBullets(lines); + removeTrailingHeaderEqualsSign(lines); } - if (config.isNormalizeOrderedListMarkers()) { - lines = normalizeOrderedListMarkers(lines); + + // Combine simple line-by-line transforms into a single in-place pass + if (config.isTitleCase() || config.isNormalizeListBullets() || config.isNormalizeOrderedListMarkers()) { + applyLineTransformations(lines); } + if (config.isEnsureHeadingBlankLines()) { - lines = ensureHeadingBlankLines(lines); + ensureHeadingBlankLines(lines); } if (config.isOneSentencePerLine()) { - lines = applySentencePerLine(lines); + applySentencePerLine(lines); } - - List result = new ArrayList<>(Arrays.asList(lines)); if (config.isCollapseConsecutiveBlankLines()) { - result = collapseBlankLines(result); + collapseBlankLines(lines); } - return String.join("\n", result); - } - // ── normalizeSetextHeadings ─────────────────────────────────────────────── + return String.join("\n", lines); + } - private String[] normalizeSetextHeadings(String[] lines) { - List result = new ArrayList<>(lines.length); + /** + * Converts setext-style headings to ATX-style headings. + * Performs the transformation in-place on the input list. + */ + private static void normalizeSetextHeadings(List lines) { BlockTracker bt = new BlockTracker(); - int i = 0; - while (i < lines.length) { - String line = lines[i]; + int readIdx = 0; + int writeIdx = 0; + while (readIdx < lines.size()) { + String line = lines.get(readIdx); if (bt.isOpen()) { - result.add(line); + lines.set(writeIdx++, line); bt.tryClose(line); - i++; + readIdx++; continue; } if (isBlockDelimiter(line)) { - result.add(line); + lines.set(writeIdx++, line); bt.open(line); - i++; + readIdx++; continue; } - if (i + 1 < lines.length) { - Integer level = detectSetextUnderline(line, lines[i + 1]); + if (readIdx + 1 < lines.size()) { + Integer level = detectSetextUnderline(line, lines.get(readIdx + 1)); if (level != null) { - result.add(ATX_PREFIX[level] + line); - i += 2; + lines.set(writeIdx++, ATX_PREFIX[level] + line); + readIdx += 2; continue; } } - result.add(line); - i++; + lines.set(writeIdx++, line); + readIdx++; + } + if (writeIdx < lines.size()) { + lines.subList(writeIdx, lines.size()).clear(); } - return result.toArray(new String[0]); } /** * Returns the heading level if {@code titleCandidate} + {@code underlineLine} * form a setext-style heading, or {@code null} otherwise. - * - *

Title candidates must be plain prose: lines that begin with structural - * AsciiDoc syntax ({@code =}, {@code [}, {@code //}, {@code .}, {@code :}, - * {@code *}, {@code -}, {@code |}, {@code +}) are never heading titles. */ - private Integer detectSetextUnderline(String titleCandidate, String underlineLine) { + @Nullable private static Integer detectSetextUnderline(String titleCandidate, CharSequence underlineLine) { if (titleCandidate.isEmpty()) { return null; } - // Structural AsciiDoc lines are never heading title candidates char first = titleCandidate.charAt(0); if (first == '=' || first == '[' || first == '.' || first == ':' || first == '*' || first == '-' || first == '|' || first == '+' @@ -215,60 +205,36 @@ private Integer detectSetextUnderline(String titleCandidate, String underlineLin return null; } } - // Underline must be at least as long as the title if (underlineLine.length() < titleCandidate.length()) { return null; } return level; } - // ── normalizeBlockDelimiters ────────────────────────────────────────────── - - /** - * Shortens over-long block delimiter lines to exactly four characters. - * - *

A line like {@code --------} (eight dashes) becomes {@code ----}. - * Lines that are already four characters are left unchanged. Setext - * heading underlines (preceded by a prose title) are also left unchanged. - * - *

A state machine tracks open/close pairs so that only the first - * occurrence of a delimiter char on an unmatched line is subject to the - * setext heuristic; once a block is open, its closing delimiter is - * normalised unconditionally. - */ - private String[] normalizeBlockDelimiters(String[] lines) { - List result = new ArrayList<>(lines.length); + private static void normalizeBlockDelimiters(List lines) { BlockTracker bt = new BlockTracker(); - for (String line : lines) { + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); if (bt.isOpen()) { - // Inside a block: normalize the closing delimiter; pass everything else through. String closed = bt.tryClose(line); - result.add(closed != null ? closed.repeat(4) : line); + if (closed != null) { + lines.set(i, closed.repeat(4)); + } } else if (isOverLongBlockDelimiter(line)) { - // Outside a block: decide if this is a setext underline or a block delimiter. - String prev = result.isEmpty() ? null : result.get(result.size() - 1); + String prev = i == 0 ? null : lines.get(i - 1); boolean isSetextUnderline = prev != null && !prev.isBlank() && detectSetextUnderline(prev, line) != null; - if (isSetextUnderline) { - result.add(line); - // Do NOT enter block-tracking state; setext underlines are not block openers. - } else { - result.add(String.valueOf(line.charAt(0)).repeat(4)); + if (!isSetextUnderline) { + lines.set(i, String.valueOf(line.charAt(0)).repeat(4)); bt.open(line); } } else if (isBlockDelimiter(line)) { - // A minimal (4-char) delimiter: enter block-tracking state. - result.add(line); bt.open(line); - } else { - result.add(line); } } - return result.toArray(new String[0]); } - /** True when every character in {@code line} equals {@code c}. */ private static boolean isAllSameChar(String line, char c) { for (int i = 0; i < line.length(); i++) { if (line.charAt(i) != c) { @@ -278,71 +244,55 @@ private static boolean isAllSameChar(String line, char c) { return true; } - /** True when the line is a block-delimiter character repeated five or more times. */ private static boolean isOverLongBlockDelimiter(String line) { return line.length() > 4 && isBlockDelimiter(line); } - // ── removeTrailingHeaderEqualsSign ──────────────────────────────────────── - - /** - * Normalises ATX heading syntax: removes symmetric trailing {@code =} signs and - * collapses any whitespace (including tabs) after the leading {@code =} signs to - * a single space. - * - *

Examples: {@code == Title ==} → {@code == Title}, - * {@code ===\tTitle} → {@code === Title}. - */ - private static String[] removeTrailingHeaderEqualsSign(String[] lines) { - String[] result = new String[lines.length]; - for (int i = 0; i < lines.length; i++) { - Matcher symmetric = SYMMETRIC_HEADING.matcher(lines[i]); + private static void removeTrailingHeaderEqualsSign(List lines) { + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + Matcher symmetric = SYMMETRIC_HEADING.matcher(line); if (symmetric.matches()) { - result[i] = symmetric.group(1) + " " + symmetric.group(2); + lines.set(i, symmetric.group(1) + " " + symmetric.group(2)); continue; } - // Normalise whitespace (tab → space) in any remaining ATX heading. - Matcher atx = ATX_HEADING.matcher(lines[i]); - result[i] = atx.matches() ? atx.group(1) + " " + atx.group(2) : lines[i]; + Matcher atx = ATX_HEADING.matcher(line); + if (atx.matches()) { + lines.set(i, atx.group(1) + " " + atx.group(2)); + } } - return result; } - // ── collapseConsecutiveBlankLines ───────────────────────────────────────── - - private static List collapseBlankLines(List lines) { - List result = new ArrayList<>(lines.size()); + /** + * Collapses multiple consecutive blank lines into a single blank line. + * Performs the transformation in-place on the input list. + */ + private static void collapseBlankLines(List lines) { + int writeIdx = 0; int consecutiveBlank = 0; - for (String line : lines) { + for (int readIdx = 0; readIdx < lines.size(); readIdx++) { + String line = lines.get(readIdx); if (line.isBlank()) { consecutiveBlank++; if (consecutiveBlank <= 1) { - result.add(line); + lines.set(writeIdx++, line); } } else { consecutiveBlank = 0; - result.add(line); + lines.set(writeIdx++, line); } } - return result; + if (writeIdx < lines.size()) { + lines.subList(writeIdx, lines.size()).clear(); + } } - // ── ensureSourceDelimiters ──────────────────────────────────────────────── - - /** - * Wraps bare {@code [source,...]} and {@code [listing]} blocks that have no - * {@code ----} delimiter with a {@code ----} / {@code ----} pair. - * - *

A block is considered "already delimited" when the line immediately - * following the attribute line is any AsciiDoc block-delimiter line. - * Content is collected until the first blank line or end of file. - */ - private static String[] ensureSourceDelimiters(String[] lines) { - List result = new ArrayList<>(lines.length + 8); + private static void ensureSourceDelimiters(List lines) { + List result = new ArrayList<>(lines.size() + 8); BlockTracker bt = new BlockTracker(); int i = 0; - while (i < lines.length) { - String line = lines[i]; + while (i < lines.size()) { + String line = lines.get(i); if (bt.isOpen()) { result.add(line); @@ -361,23 +311,20 @@ private static String[] ensureSourceDelimiters(String[] lines) { if (SOURCE_BLOCK_ATTR.matcher(line).matches()) { result.add(line); i++; - if (i < lines.length) { - String next = lines[i]; + if (i < lines.size()) { + String next = lines.get(i); if (isBlockDelimiter(next)) { - // Already has a delimiter — enter block state normally result.add(next); bt.open(next); i++; } else if (!next.isBlank() && !next.startsWith("[")) { - // No delimiter: wrap the following paragraph result.add("----"); - while (i < lines.length && !lines[i].isBlank()) { - result.add(lines[i]); + while (i < lines.size() && !lines.get(i).isBlank()) { + result.add(lines.get(i)); i++; } result.add("----"); } - // blank or another attribute line: leave as-is } continue; } @@ -385,21 +332,16 @@ private static String[] ensureSourceDelimiters(String[] lines) { result.add(line); i++; } - return result.toArray(new String[0]); + lines.clear(); + lines.addAll(result); } - // ── ensureHeadingBlankLines ─────────────────────────────────────────────── - - /** - * Ensures every ATX section heading is preceded and followed by a blank line. - * Lines inside delimited blocks are not touched. - */ - private static String[] ensureHeadingBlankLines(String[] lines) { - List result = new ArrayList<>(lines.length + 8); + private static void ensureHeadingBlankLines(List lines) { + List result = new ArrayList<>(lines.size() + 8); BlockTracker bt = new BlockTracker(); - for (int i = 0; i < lines.length; i++) { - String line = lines[i]; + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); if (bt.isOpen()) { result.add(line); @@ -413,105 +355,69 @@ private static String[] ensureHeadingBlankLines(String[] lines) { } if (SECTION_HEADING.matcher(line).matches()) { - // blank line before (skip if first line or previous is already blank) if (!result.isEmpty() && !result.get(result.size() - 1).isBlank()) { result.add(""); } result.add(line); - // blank line after (skip if last line or next is already blank) - if (i + 1 < lines.length && !lines[i + 1].isBlank()) { + if (i + 1 < lines.size() && !lines.get(i + 1).isBlank()) { result.add(""); } } else { result.add(line); } } - return result.toArray(new String[0]); + lines.clear(); + lines.addAll(result); } - // ── removeTrailingWhitespace ────────────────────────────────────────────── - - private static String[] removeTrailingWhitespace(String[] lines) { - String[] result = new String[lines.length]; - for (int i = 0; i < lines.length; i++) { - result[i] = lines[i].stripTrailing(); + private static void removeTrailingWhitespace(List lines) { + for (int i = 0; i < lines.size(); i++) { + lines.set(i, lines.get(i).stripTrailing()); } - return result; } - // ── processLinesSkippingBlocks ──────────────────────────────────────────── - - /** - * Applies {@code transform} to every line that is outside a delimited block. - * Lines that open or close a block, and all lines between them, are passed - * through unchanged. - */ - private static String[] processLinesSkippingBlocks(String[] lines, UnaryOperator transform) { - String[] result = new String[lines.length]; + private void applyLineTransformations(List lines) { BlockTracker bt = new BlockTracker(); - for (int i = 0; i < lines.length; i++) { - String line = lines[i]; + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); if (bt.isOpen()) { - result[i] = line; bt.tryClose(line); } else if (isBlockDelimiter(line)) { - result[i] = line; bt.open(line); } else { - result[i] = transform.apply(line); + if (config.isTitleCase()) { + line = titleCaseLine(line); + } + if (config.isNormalizeListBullets() && line.startsWith("- ")) { + line = "* " + line.substring(2); + } + if (config.isNormalizeOrderedListMarkers()) { + line = normalizeOrderedListMarker(line); + } + lines.set(i, line); } } - return result; - } - - // ── normalizeListBullets ────────────────────────────────────────────────── - - /** - * Converts dash-style unordered list items ({@code - item}) to the standard - * AsciiDoc asterisk style ({@code * item}). Lines inside delimited blocks - * are passed through unchanged. - */ - private static String[] normalizeListBullets(String[] lines) { - return processLinesSkippingBlocks(lines, - line -> line.startsWith("- ") ? "* " + line.substring(2) : line); - } - - // ── normalizeOrderedListMarkers ─────────────────────────────────────────── - - /** - * Converts explicit-number ordered list items ({@code 1. item}) to the - * AsciiDoc auto-numbered dot style ({@code . item}). Lines inside - * delimited blocks are passed through unchanged. - */ - private static String[] normalizeOrderedListMarkers(String[] lines) { - return processLinesSkippingBlocks(lines, line -> { - if (line.isEmpty() || line.charAt(0) < '0' || line.charAt(0) > '9') - return line; - int i = 1; - while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') - i++; - if (i + 1 >= line.length() || line.charAt(i) != '.') - return line; - char sep = line.charAt(i + 1); - if (sep != ' ' && sep != '\t') - return line; - return ". " + line.substring(i + 2); - }); } - // ── titleCase ───────────────────────────────────────────────────────────── - - private static String[] applyTitleCase(String[] lines) { - return processLinesSkippingBlocks(lines, AsciidocFormatterFunc::titleCaseLine); + private static String normalizeOrderedListMarker(String line) { + if (line.isEmpty() || line.charAt(0) < '0' || line.charAt(0) > '9') + return line; + int i = 1; + while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') + i++; + if (i + 1 >= line.length() || line.charAt(i) != '.') + return line; + char sep = line.charAt(i + 1); + if (sep != ' ' && sep != '\t') + return line; + return ". " + line.substring(i + 2); } private static String titleCaseLine(String line) { - // Section heading: = Title, == Title, ... Matcher m = SECTION_HEADING.matcher(line); if (m.matches()) { return m.group(1) + " " + toTitleCase(m.group(2)); } - // Block title: .Title (single dot, not .. and not dot-space) if (line.length() > 1 && line.charAt(0) == '.' && line.charAt(1) != '.' && line.charAt(1) != ' ') { return "." + toTitleCase(line.substring(1)); } @@ -519,7 +425,7 @@ private static String titleCaseLine(String line) { } private static String toTitleCase(String text) { - String[] words = text.split(" +", -1); // " +" avoids empty tokens from consecutive spaces + String[] words = text.split(" +", -1); StringBuilder sb = new StringBuilder(); for (int i = 0; i < words.length; i++) { if (i > 0) { @@ -535,16 +441,13 @@ private static String capitalizeWordForTitle(String word, boolean forceCapitaliz if (word.isEmpty()) { return word; } - // Skip words containing AsciiDoc special markup (attributes, code spans, block attrs) if (word.contains("{") || word.contains("`") || word.contains("[")) { return word; } - // Skip AsciiDoc macros (word:target — colon not at end) int colonIdx = word.indexOf(':'); if (colonIdx > 0 && colonIdx < word.length() - 1) { return word; } - // Find first letter int firstLetter = -1; for (int i = 0; i < word.length(); i++) { if (Character.isLetter(word.charAt(i))) { @@ -555,7 +458,6 @@ private static String capitalizeWordForTitle(String word, boolean forceCapitaliz if (firstLetter < 0) { return word; } - // Extract only letters from firstLetter onward for lowercase-set membership test StringBuilder coreBuilder = new StringBuilder(); for (int i = firstLetter; i < word.length(); i++) { char c = word.charAt(i); @@ -567,30 +469,25 @@ private static String capitalizeWordForTitle(String word, boolean forceCapitaliz if (!forceCapitalize && TITLE_CASE_LOWERCASE.contains(core)) { return word.toLowerCase(Locale.ROOT); } - // Uppercase first letter, leave the rest unchanged (preserves acronyms like API) return word.substring(0, firstLetter) + Character.toUpperCase(word.charAt(firstLetter)) + word.substring(firstLetter + 1); } - // ── oneSentencePerLine ──────────────────────────────────────────────────── - - private String[] applySentencePerLine(String[] lines) { - List result = new ArrayList<>(lines.length); + private static void applySentencePerLine(List lines) { + List result = new ArrayList<>(lines.size()); List paragraphBuffer = new ArrayList<>(); BlockTracker bt = new BlockTracker(); - for (int i = 0; i < lines.length; i++) { - String line = lines[i]; + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); - // ── inside a delimited block: pass through until matching closing delimiter if (bt.isOpen()) { result.add(line); bt.tryClose(line); continue; } - // ── opening delimiter: flush any accumulated paragraph, enter block if (isBlockDelimiter(line)) { flushParagraph(paragraphBuffer, result); result.add(line); @@ -598,31 +495,26 @@ private String[] applySentencePerLine(String[] lines) { continue; } - // ── setext heading pair: lookahead to avoid mangling title + underline. - // Use detectSetextUnderline (same logic as normalizeSetextHeadings) so that - // structural lines like [source,java] or short dash-lines like -- are not - // mistakenly treated as heading pairs. - if (i + 1 < lines.length && detectSetextUnderline(line, lines[i + 1]) != null) { + if (i + 1 < lines.size() && detectSetextUnderline(line, lines.get(i + 1)) != null) { flushParagraph(paragraphBuffer, result); result.add(line); - result.add(lines[i + 1]); + result.add(lines.get(i + 1)); i++; continue; } - // ── blank or structurally special line: flush paragraph, pass through if (line.isBlank() || isSpecialLine(line)) { flushParagraph(paragraphBuffer, result); result.add(line); continue; } - // ── plain paragraph text: accumulate paragraphBuffer.add(line); } flushParagraph(paragraphBuffer, result); - return result.toArray(new String[0]); + lines.clear(); + lines.addAll(result); } private static void flushParagraph(List buffer, List result) { @@ -648,44 +540,36 @@ private static boolean isBlockDelimiter(String line) { return true; } - /** - * Returns true for lines that are structural AsciiDoc syntax and must be - * emitted verbatim rather than accumulated into a paragraph. - */ private static boolean isSpecialLine(String line) { if (line.isEmpty()) return false; char first = line.charAt(0); if (first == '=') - return true; // headings: = Title, == Section … + return true; if (first == '[') - return true; // block attributes: [source,java] + return true; if (line.startsWith("//")) - return true; // line or block comments - // attribute entries :attr: value (but not :: description-list markers) + return true; if (first == ':' && line.length() > 1 && line.charAt(1) != ':') return true; if (first == '|') - return true; // table cells + return true; if (line.equals("+")) - return true; // list continuation + return true; if (first == ' ' || first == '\t') - return true; // indented literal paragraph - // unordered list items: * item, ** item, - item, -- item, … + return true; if (first == '*' || first == '-') { int i = 1; while (i < line.length() && line.charAt(i) == first) i++; return i < line.length() && line.charAt(i) == ' '; } - // ordered list items (AsciiDoc auto-number): . item, .. item, … if (first == '.') { int i = 1; while (i < line.length() && line.charAt(i) == '.') i++; return i < line.length() && line.charAt(i) == ' '; } - // explicit numbered list items: 1. item, 42.\titem if (first >= '0' && first <= '9') { int i = 1; while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') @@ -694,14 +578,12 @@ private static boolean isSpecialLine(String line) { && (line.charAt(i + 1) == ' ' || line.charAt(i + 1) == '\t'); } if (line.startsWith("<<<")) - return true; // page break + return true; if (line.equals("'''")) - return true; // horizontal rule (thematic break) - // block macros (include::, toc::, image::, …) and description-list terms (term::) + return true; return isBlockMacroOrTerm(line); } - /** True when {@code line} is a block-macro call or description-list term ({@code word::…}). */ private static boolean isBlockMacroOrTerm(String line) { int len = line.length(); int i = 0; @@ -716,8 +598,6 @@ private static boolean isBlockMacroOrTerm(String line) { return i > 0 && i + 1 < len && line.charAt(i) == ':' && line.charAt(i + 1) == ':'; } - // ── sentence splitting ──────────────────────────────────────────────────── - private static List splitIntoSentences(String text) { if (text.isEmpty()) { return Collections.emptyList(); @@ -732,7 +612,6 @@ private static List splitIntoSentences(String text) { if (c == '.' || c == '!' || c == '?') { - // Skip ellipsis (two or more consecutive dots) if (c == '.' && i + 1 < text.length() && text.charAt(i + 1) == '.') { i++; while (i < text.length() && text.charAt(i) == '.') { @@ -741,25 +620,21 @@ private static List splitIntoSentences(String text) { continue; } - // Abbreviations: only relevant for full stops if (c == '.' && isAbbreviationContext(text, i)) { i++; continue; } - // Skip optional closing characters after the punctuation mark int j = i + 1; while (j < text.length() && isSentenceClosingChar(text.charAt(j))) { j++; } - // End of string — remaining text collected after the loop if (j >= text.length()) { i = j; continue; } - // Sentence boundary: whitespace followed by an uppercase letter (or end) if (Character.isWhitespace(text.charAt(j))) { int k = j; while (k < text.length() && Character.isWhitespace(text.charAt(k))) { @@ -788,11 +663,9 @@ private static List splitIntoSentences(String text) { } private static boolean isAbbreviationContext(String text, int dotPos) { - // Digit before dot: decimal numbers and numbered items such as "section 1.2." if (dotPos > 0 && Character.isDigit(text.charAt(dotPos - 1))) { return true; } - // Extract the alphabetic word immediately before the dot int wordEnd = dotPos; int wordStart = wordEnd - 1; while (wordStart >= 0 && Character.isLetter(text.charAt(wordStart))) { @@ -803,7 +676,6 @@ private static boolean isAbbreviationContext(String text, int dotPos) { return false; } String word = text.substring(wordStart, wordEnd); - // Single lowercase letter covers components of e.g., i.e., a.k.a., etc. if (word.length() == 1 && Character.isLowerCase(word.charAt(0))) { return true; } @@ -812,17 +684,10 @@ private static boolean isAbbreviationContext(String text, int dotPos) { private static boolean isSentenceClosingChar(char c) { return c == ')' || c == ']' || c == '"' || c == '\'' - || c == '\u2019' /* right single quotation mark */ - || c == '\u201D' /* right double quotation mark */; + || c == '\u2019' + || c == '\u201D'; } - // ── BlockTracker ────────────────────────────────────────────────────────── - - /** - * Tracks the open/close state of a single AsciiDoc delimited block across a - * line scan. All block-aware methods share this class instead of each - * maintaining their own {@code openDelimiterChar} variable. - */ private static final class BlockTracker { private char delimChar = '\0'; @@ -834,12 +699,7 @@ void open(String line) { delimChar = line.charAt(0); } - /** - * Tests whether {@code line} closes the currently open block. - * If so, resets the open state and returns the delimiter character; - * returns {@code null} if the block remains open. - */ - String tryClose(String line) { + @Nullable String tryClose(String line) { if (delimChar != '\0' && line.length() >= 4 && isAllSameChar(line, delimChar)) { String closed = String.valueOf(delimChar); delimChar = '\0'; diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java index 328a78ade8..8bb7b3700d 100644 --- a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java @@ -23,8 +23,6 @@ class AsciidocFormatterFuncTest { - // ── helpers ─────────────────────────────────────────────────────────────── - /** Returns a formatter with every feature disabled, then applies {@code customizer}. */ private static AsciidocFormatterFunc funcWith(Consumer customizer) { AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); @@ -95,8 +93,6 @@ private static String apply(AsciidocFormatterFunc f, String input) { } } - // ── normalizeSetextHeadings ─────────────────────────────────────────────── - @Test void convertsLevel0SetextHeading() { assertThat(apply(funcSetext(), "Document Title\n==============")) @@ -187,8 +183,6 @@ void setextNormalizationIsIdempotent() throws Exception { assertThat(twice).isEqualTo(once); } - // ── collapseConsecutiveBlankLines ───────────────────────────────────────── - @Test void singleBlankLinePreserved() { assertThat(apply(funcCollapse(), "A\n\nB")).isEqualTo("A\n\nB"); @@ -232,8 +226,6 @@ void collapseIsIdempotent() throws Exception { assertThat(twice).isEqualTo(once); } - // ── one sentence per line – basic ───────────────────────────────────────── - @Test void splitsTwoSentencesOnOneLine() { String input = "First sentence. Second sentence."; @@ -264,8 +256,6 @@ void idempotent() throws Exception { assertThat(twice).isEqualTo(once); } - // ── abbreviation handling ───────────────────────────────────────────────── - @Test void drAbbreviationIsNotASentenceBoundary() { String input = "Consult Dr. Smith before proceeding. Then continue."; @@ -294,8 +284,6 @@ void doesNotSplitEllipsis() { "Well... that is interesting.\nNext point."); } - // ── structural lines must pass through untouched ───────────────────────── - @Test void doesNotTouchHeadings() { String input = "== Section Title\n\nParagraph text."; @@ -320,8 +308,6 @@ void doesNotTouchListItems() { assertThat(apply(func(true), input)).isEqualTo(input); } - // ── content inside delimited blocks must pass through untouched ────────── - @Test void doesNotReformatInsideListingBlock() { String input = "----\nFirst sentence. Second sentence.\n----"; @@ -334,8 +320,6 @@ void doesNotReformatInsideExampleBlock() { assertThat(apply(func(true), input)).isEqualTo(input); } - // ── block macros and page breaks must pass through untouched ──────────── - @Test void pageBreakIsNotJoinedWithAdjacentMacros() { // toc::[], <<<, and include:: are structural – they must never be accumulated @@ -367,8 +351,6 @@ void horizontalRulePassedThrough() { .isEqualTo("Sentence one.\n'''\nSentence two."); } - // ── blank lines separate paragraphs (no cross-paragraph joining) ───────── - @Test void blankLineSeparatesParagraphs() { String input = "Paragraph one sentence one. Sentence two.\n\nParagraph two."; @@ -376,16 +358,12 @@ void blankLineSeparatesParagraphs() { "Paragraph one sentence one.\nSentence two.\n\nParagraph two."); } - // ── setext heading lookahead ────────────────────────────────────────────── - @Test void doesNotMangleSetextHeading() { String input = "My Section\n----------\n\nParagraph text."; assertThat(apply(func(true), input)).isEqualTo(input); } - // ── normalizeBlockDelimiters ────────────────────────────────────────────── - @Test void shortensLongDashDelimiter() { assertThat(apply(funcDelimiters(), "--------\ncode\n--------")) @@ -457,8 +435,6 @@ void blockDelimiterNormalizationIsIdempotent() throws Exception { assertThat(twice).isEqualTo(once); } - // ── removeTrailingHeaderEqualsSign ──────────────────────────────────────── - @Test void removesTrailingEqualsFromH2() { assertThat(apply(funcTrailingEquals(), "== Section Title ==")) @@ -503,8 +479,6 @@ void removeTrailingEqualsIsIdempotent() throws Exception { assertThat(twice).isEqualTo(once); } - // ── splitIntoSentences unit tests ───────────────────────────────────────── - @Test void singleSentenceReturnedAsIs() { assertThat(apply(func(true), "Just one sentence.")).isEqualTo("Just one sentence."); @@ -516,8 +490,6 @@ void lowercaseAfterPeriodIsNotASplit() { .isEqualTo("lowercase follows. not a new sentence. no split here."); } - // ── titleCase ───────────────────────────────────────────────────────────── - @Test void titleCasesLevel1SectionHeading() { assertThat(apply(funcTitleCase(), "= examples of title case")) @@ -628,8 +600,6 @@ void dotSpaceLineNotTreatedAsBlockTitle() { assertThat(apply(funcTitleCase(), input)).isEqualTo(input); } - // ── removeTrailingWhitespace ────────────────────────────────────────────── - @Test void trailingSpacesRemovedFromLine() { assertThat(apply(funcTrailingWhitespace(), "line with trailing spaces ")) @@ -667,8 +637,6 @@ void removeTrailingWhitespaceIsIdempotent() throws Exception { assertThat(twice).isEqualTo(once); } - // ── normalizeListBullets ────────────────────────────────────────────────── - @Test void dashListItemConvertedToAsterisk() { assertThat(apply(funcListBullets(), "- first item")) @@ -714,8 +682,6 @@ void listBulletsNormalizationIsIdempotent() throws Exception { assertThat(twice).isEqualTo(once); } - // ── normalizeOrderedListMarkers ─────────────────────────────────────────── - @Test void numberedListItemConvertedToAsciiDocStyle() { assertThat(apply(funcOrderedList(), "1. First item")) @@ -776,8 +742,6 @@ void numberedListWithTabNotMangledByOneSentencePerLine() { assertThat(apply(func(true), input)).isEqualTo(input); } - // ── removeTrailingHeaderEqualsSign – heading whitespace normalization ────── - @Test void tabAfterHeadingMarkerNormalizedToSpace() { assertThat(apply(funcTrailingEquals(), "===\tNginx")) @@ -790,8 +754,6 @@ void multipleSpacesAfterHeadingMarkerCollapsed() { .isEqualTo("== Title"); } - // ── ensureHeadingBlankLines ─────────────────────────────────────────────── - @Test void blankLineAddedAfterHeading() { assertThat(apply(funcHeadingBlanks(), "== Section\nContent")) @@ -843,8 +805,6 @@ void ensureHeadingBlankLinesIsIdempotent() throws Exception { assertThat(twice).isEqualTo(once); } - // ── ensureSourceDelimiters ──────────────────────────────────────────────── - @Test void sourceBlockWithoutDelimiterGetsWrapped() { String input = "[source,java]\npublic void foo() {}"; @@ -930,8 +890,6 @@ void sourceBlockWithIdShorthandGetsWrapped() { .isEqualTo("[source#intro,java]\n----\npublic void foo() {}\n----"); } - // ── combined transformations ────────────────────────────────────────────── - @Test void setextNormalizationThenHeadingBlankLinesThenTitleCase() { // Exercises the three ordering-dependent transformations in sequence: @@ -947,8 +905,6 @@ void setextNormalizationThenHeadingBlankLinesThenTitleCase() { .isEqualTo("some text\n\n== My Cool Section\n\nsome body"); } - // ── CRLF input normalization ────────────────────────────────────────────── - @Test void crlfLineEndingsNormalizedToLf() { // removeTrailingWhitespace strips \r, and join("\n") produces LF-only output From 65475d7320dc03401c14bd25fae43b1383909c21 Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 14:33:48 +0200 Subject: [PATCH 08/16] Fix sentence-per-line bugs and improve structure / regexes --- .../asciidoc/AsciidocFormatterFunc.java | 66 ++++++++----------- .../asciidoc/AsciidocFormatterFuncTest.java | 39 +++++++++++ 2 files changed, 68 insertions(+), 37 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index f0c1bca63e..c4c8a1b79c 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -40,18 +40,14 @@ public class AsciidocFormatterFunc implements FormatterFunc { private static final Pattern SYMMETRIC_HEADING = Pattern.compile("^(={1,6})\\s+(.*\\S)\\s+=+\\s*$"); // Section heading: = Title or == Title, etc. - // Captured groups: (1) leading equals, (2) title text - private static final Pattern SECTION_HEADING = Pattern.compile("^(={1,6})\\s+(.+)$"); + // Captured groups: (1) leading equals, (2) trimmed title text + private static final Pattern SECTION_HEADING = Pattern.compile("^(={1,6})\\s+(\\S.*?)\\s*$"); // Words lowercased in title case (articles, conjunctions, short prepositions) private static final Set TITLE_CASE_LOWERCASE = Set.of( "a", "an", "the", "and", "but", "or", "nor", "for", "yet", "so", - "at", "by", "in", "of", "on", "to", "up", "as", "off", "out", "per", "via"); - - // Any ATX heading, used to normalise whitespace (tab -> space) after the = signs - // Captured groups: (1) leading equals, (2) trimmed title text - private static final Pattern ATX_HEADING = Pattern.compile("^(={1,6})\\s+(\\S.*?)\\s*$"); + "at", "by", "in", "of", "on", "to", "up", "as", "off", "out", "per", "via", "from", "with"); // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], [source#id,java], etc. private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%#].*"); @@ -256,9 +252,9 @@ private static void removeTrailingHeaderEqualsSign(List lines) { lines.set(i, symmetric.group(1) + " " + symmetric.group(2)); continue; } - Matcher atx = ATX_HEADING.matcher(line); - if (atx.matches()) { - lines.set(i, atx.group(1) + " " + atx.group(2)); + Matcher section = SECTION_HEADING.matcher(line); + if (section.matches()) { + lines.set(i, section.group(1) + " " + section.group(2)); } } } @@ -541,46 +537,42 @@ private static boolean isBlockDelimiter(String line) { } private static boolean isSpecialLine(String line) { - if (line.isEmpty()) + if (line.isEmpty()) { return false; + } char first = line.charAt(0); - if (first == '=') - return true; - if (first == '[') - return true; - if (line.startsWith("//")) + if (first == '=' || first == '[' || first == '|' || first == ' ' || first == '\t') { return true; - if (first == ':' && line.length() > 1 && line.charAt(1) != ':') - return true; - if (first == '|') - return true; - if (line.equals("+")) + } + if (line.startsWith("//") || line.startsWith("<<<") || line.equals("'''") || line.equals("+")) { return true; - if (first == ' ' || first == '\t') + } + if (first == ':' && line.length() > 1 && line.charAt(1) != ':') { return true; - if (first == '*' || first == '-') { - int i = 1; - while (i < line.length() && line.charAt(i) == first) - i++; - return i < line.length() && line.charAt(i) == ' '; } - if (first == '.') { + if (first == '.' || first == '*' || first == '-') { + if (line.length() > 1 && line.charAt(1) != first && line.charAt(1) != ' ') { + if (first == '.') { + return true; // Block title (.Title) + } + } int i = 1; - while (i < line.length() && line.charAt(i) == '.') + while (i < line.length() && line.charAt(i) == first) { i++; + } + if (i == line.length() && i >= 3) { + return true; // Horizontal rule (--- or ***) + } return i < line.length() && line.charAt(i) == ' '; } - if (first >= '0' && first <= '9') { + if (Character.isDigit(first)) { int i = 1; - while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') + while (i < line.length() && Character.isDigit(line.charAt(i))) { i++; + } return i + 1 < line.length() && line.charAt(i) == '.' && (line.charAt(i + 1) == ' ' || line.charAt(i + 1) == '\t'); } - if (line.startsWith("<<<")) - return true; - if (line.equals("'''")) - return true; return isBlockMacroOrTerm(line); } @@ -676,8 +668,8 @@ private static boolean isAbbreviationContext(String text, int dotPos) { return false; } String word = text.substring(wordStart, wordEnd); - if (word.length() == 1 && Character.isLowerCase(word.charAt(0))) { - return true; + if (word.length() == 1) { + return true; // Initials (e.g., A. Smith) } return ABBREVIATIONS.contains(word.toLowerCase(Locale.ROOT)); } diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java index 8bb7b3700d..1651ab8e4a 100644 --- a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java @@ -263,6 +263,27 @@ void drAbbreviationIsNotASentenceBoundary() { "Consult Dr. Smith before proceeding.\nThen continue."); } + @Test + void initialIsNotASentenceBoundary() { + String input = "The author is A. Smith. He is famous."; + assertThat(apply(func(true), input)).isEqualTo( + "The author is A. Smith.\nHe is famous."); + } + + @Test + void abbreviationFollowedByCapitalIsNotASentenceBoundary() { + String input = "Item etc. And more. Next sentence."; + assertThat(apply(func(true), input)).isEqualTo( + "Item etc. And more.\nNext sentence."); + } + + @Test + void blockTitleIsSpecialLine() { + String input = ".Block Title\nThis is a sentence. This is another."; + assertThat(apply(func(true), input)).isEqualTo( + ".Block Title\nThis is a sentence.\nThis is another."); + } + @Test void doesNotSplitInsideEgAbbreviation() { String input = "Use a tool (e.g. Spotless) for formatting. It helps."; @@ -351,6 +372,18 @@ void horizontalRulePassedThrough() { .isEqualTo("Sentence one.\n'''\nSentence two."); } + @Test + void dashHorizontalRulePassedThrough() { + assertThat(apply(func(true), "Sentence one.\n---\nSentence two.")) + .isEqualTo("Sentence one.\n---\nSentence two."); + } + + @Test + void asteriskHorizontalRulePassedThrough() { + assertThat(apply(func(true), "Sentence one.\n***\nSentence two.")) + .isEqualTo("Sentence one.\n***\nSentence two."); + } + @Test void blankLineSeparatesParagraphs() { String input = "Paragraph one sentence one. Sentence two.\n\nParagraph two."; @@ -496,6 +529,12 @@ void titleCasesLevel1SectionHeading() { .isEqualTo("= Examples of Title Case"); } + @Test + void titleCaseHandlesWordsWithPunctuation() { + assertThat(apply(funcTitleCase(), "== word, and another")) + .isEqualTo("== Word, and Another"); + } + @Test void titleCasesLevel2SectionHeading() { assertThat(apply(funcTitleCase(), "== the quick brown fox")) From efb41cee1710f3e4e51cfdc28dd4c099c3a8687c Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 14:50:50 +0200 Subject: [PATCH 09/16] Split AsciidocFormatterFunc into multiple classes --- .../asciidoc/AsciidocBlockHandler.java | 106 +++ .../asciidoc/AsciidocFormatterFunc.java | 647 +----------------- .../asciidoc/AsciidocHeadingHandler.java | 120 ++++ .../asciidoc/AsciidocLineHandler.java | 137 ++++ .../asciidoc/AsciidocSentenceHandler.java | 178 +++++ .../spotless/asciidoc/AsciidocSupport.java | 165 +++++ .../spotless/asciidoc/BlockTracker.java | 39 ++ 7 files changed, 758 insertions(+), 634 deletions(-) create mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java create mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java create mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java create mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java create mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java create mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java new file mode 100644 index 0000000000..1ffee37309 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.regex.Pattern; + +/** Handles transformations for Asciidoc blocks (delimiters, source blocks). */ +final class AsciidocBlockHandler { + private AsciidocBlockHandler() {} + + // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], [source#id,java], etc. + private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%#].*"); + + static void normalizeBlockDelimiters(List lines) { + BlockTracker bt = new BlockTracker(); + + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (bt.isOpen()) { + String closed = bt.tryClose(line); + if (closed != null) { + lines.set(i, closed.repeat(4)); + } + } else if (isOverLongBlockDelimiter(line)) { + String prev = i == 0 ? null : lines.get(i - 1); + boolean notSetextUnderline = prev == null || prev.isBlank() + || AsciidocSupport.detectSetextUnderline(prev, line) == null; + if (notSetextUnderline) { + lines.set(i, String.valueOf(line.charAt(0)).repeat(4)); + bt.open(line); + } + } else if (AsciidocSupport.isBlockDelimiter(line)) { + bt.open(line); + } + } + } + + private static boolean isOverLongBlockDelimiter(CharSequence line) { + return line.length() > 4 && AsciidocSupport.isBlockDelimiter(line); + } + + static void ensureSourceDelimiters(List lines) { + Collection result = new ArrayList<>(lines.size() + 8); + BlockTracker bt = new BlockTracker(); + int i = 0; + while (i < lines.size()) { + String line = lines.get(i); + + if (bt.isOpen()) { + result.add(line); + bt.tryClose(line); + i++; + continue; + } + + if (AsciidocSupport.isBlockDelimiter(line)) { + result.add(line); + bt.open(line); + i++; + continue; + } + + if (SOURCE_BLOCK_ATTR.matcher(line).matches()) { + result.add(line); + i++; + if (i < lines.size()) { + String next = lines.get(i); + if (AsciidocSupport.isBlockDelimiter(next)) { + result.add(next); + bt.open(next); + i++; + } else if (!next.isBlank() && !next.startsWith("[")) { + result.add("----"); + while (i < lines.size() && !lines.get(i).isBlank()) { + result.add(lines.get(i)); + i++; + } + result.add("----"); + } + } + continue; + } + + result.add(line); + i++; + } + lines.clear(); + lines.addAll(result); + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index c4c8a1b79c..dcfd782867 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -17,59 +17,19 @@ import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Matcher; import java.util.regex.Pattern; import com.diffplug.spotless.FormatterFunc; import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; +/** + * A formatter function for Asciidoc that applies various formatting rules + * based on the provided configuration. + */ public class AsciidocFormatterFunc implements FormatterFunc { - // Single source of truth for block-delimiter characters; isBlockDelimiter is derived from this. - // To add a new delimiter type, append its character here only. - private static final String BLOCK_DELIMITER_CHARS = "-=.*_+/"; - - // Heading with trailing = signs: == Title == or === Title === - // Captured groups: (1) leading equals, (2) title text (trimmed) - private static final Pattern SYMMETRIC_HEADING = Pattern.compile("^(={1,6})\\s+(.*\\S)\\s+=+\\s*$"); - - // Section heading: = Title or == Title, etc. - // Captured groups: (1) leading equals, (2) trimmed title text - private static final Pattern SECTION_HEADING = Pattern.compile("^(={1,6})\\s+(\\S.*?)\\s*$"); - - // Words lowercased in title case (articles, conjunctions, short prepositions) - private static final Set TITLE_CASE_LOWERCASE = Set.of( - "a", "an", "the", - "and", "but", "or", "nor", "for", "yet", "so", - "at", "by", "in", "of", "on", "to", "up", "as", "off", "out", "per", "via", "from", "with"); - - // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], [source#id,java], etc. - private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%#].*"); - - // ATX heading prefixes for setext -> ATX conversion: ATX_PREFIX[n] = "=".repeat(n+1) + " " - private static final String[] ATX_PREFIX = {"= ", "== ", "=== ", "==== ", "===== ", "====== "}; - - // Pre-compiled whitespace-run pattern used to collapse internal whitespace in flushParagraph. - private static final Pattern MULTI_WHITESPACE = Pattern.compile("\\s+"); - - // Known abbreviations that end with a period but do not end a sentence - private static final Set ABBREVIATIONS = Set.of( - "mr", "mrs", "ms", "dr", "prof", "sr", "jr", - "vs", "etc", "approx", "dept", "fig", "no", "vol", - "ch", "sec", "ref", "rev", "st", "mt", "ft", - "ave", "blvd", "rd", "pp", "al", "ed", "eds", - "corp", "inc", "ltd", "llc", - "jan", "feb", "mar", "apr", "jun", "jul", - "aug", "sep", "sept", "oct", "nov", "dec", - // German abbreviations - "bspw", "bzw", "bzgl", "ca", "evtl", "exkl", "inkl", "sog"); - private final AsciidocFormatterConfig config; public AsciidocFormatterFunc(AsciidocFormatterConfig config) { @@ -87,617 +47,36 @@ public String apply(@NonNull String input) throws Exception { // normalizeSetextHeadings before ensureHeadingBlankLines // - setext headings are converted to ATX first so they receive blank-line padding. if (config.isRemoveTrailingWhitespace()) { - removeTrailingWhitespace(lines); + AsciidocSupport.removeTrailingWhitespace(lines); } if (config.isEnsureSourceDelimiters()) { - ensureSourceDelimiters(lines); + AsciidocBlockHandler.ensureSourceDelimiters(lines); } if (config.isNormalizeSetextHeadings()) { - normalizeSetextHeadings(lines); + AsciidocHeadingHandler.normalizeSetextHeadings(lines); } if (config.isNormalizeBlockDelimiters()) { - normalizeBlockDelimiters(lines); + AsciidocBlockHandler.normalizeBlockDelimiters(lines); } if (config.isRemoveTrailingHeaderEqualsSign()) { - removeTrailingHeaderEqualsSign(lines); + AsciidocHeadingHandler.removeTrailingHeaderEqualsSign(lines); } // Combine simple line-by-line transforms into a single in-place pass if (config.isTitleCase() || config.isNormalizeListBullets() || config.isNormalizeOrderedListMarkers()) { - applyLineTransformations(lines); + AsciidocLineHandler.applyLineTransformations(lines, config); } if (config.isEnsureHeadingBlankLines()) { - ensureHeadingBlankLines(lines); + AsciidocHeadingHandler.ensureHeadingBlankLines(lines); } if (config.isOneSentencePerLine()) { - applySentencePerLine(lines); + AsciidocSentenceHandler.applySentencePerLine(lines); } if (config.isCollapseConsecutiveBlankLines()) { - collapseBlankLines(lines); + AsciidocSupport.collapseBlankLines(lines); } return String.join("\n", lines); } - - /** - * Converts setext-style headings to ATX-style headings. - * Performs the transformation in-place on the input list. - */ - private static void normalizeSetextHeadings(List lines) { - BlockTracker bt = new BlockTracker(); - int readIdx = 0; - int writeIdx = 0; - while (readIdx < lines.size()) { - String line = lines.get(readIdx); - if (bt.isOpen()) { - lines.set(writeIdx++, line); - bt.tryClose(line); - readIdx++; - continue; - } - if (isBlockDelimiter(line)) { - lines.set(writeIdx++, line); - bt.open(line); - readIdx++; - continue; - } - if (readIdx + 1 < lines.size()) { - Integer level = detectSetextUnderline(line, lines.get(readIdx + 1)); - if (level != null) { - lines.set(writeIdx++, ATX_PREFIX[level] + line); - readIdx += 2; - continue; - } - } - lines.set(writeIdx++, line); - readIdx++; - } - if (writeIdx < lines.size()) { - lines.subList(writeIdx, lines.size()).clear(); - } - } - - /** - * Returns the heading level if {@code titleCandidate} + {@code underlineLine} - * form a setext-style heading, or {@code null} otherwise. - */ - @Nullable private static Integer detectSetextUnderline(String titleCandidate, CharSequence underlineLine) { - if (titleCandidate.isEmpty()) { - return null; - } - char first = titleCandidate.charAt(0); - if (first == '=' || first == '[' || first == '.' || first == ':' - || first == '*' || first == '-' || first == '|' || first == '+' - || titleCandidate.startsWith("//")) { - return null; - } - if (underlineLine.isEmpty()) { - return null; - } - char underlineChar = underlineLine.charAt(0); - int level; - switch (underlineChar) { - case '=': - level = 0; - break; - case '-': - level = 1; - break; - case '~': - level = 2; - break; - case '^': - level = 3; - break; - case '+': - level = 4; - break; - default: - return null; - } - for (int j = 1; j < underlineLine.length(); j++) { - if (underlineLine.charAt(j) != underlineChar) { - return null; - } - } - if (underlineLine.length() < titleCandidate.length()) { - return null; - } - return level; - } - - private static void normalizeBlockDelimiters(List lines) { - BlockTracker bt = new BlockTracker(); - - for (int i = 0; i < lines.size(); i++) { - String line = lines.get(i); - if (bt.isOpen()) { - String closed = bt.tryClose(line); - if (closed != null) { - lines.set(i, closed.repeat(4)); - } - } else if (isOverLongBlockDelimiter(line)) { - String prev = i == 0 ? null : lines.get(i - 1); - boolean isSetextUnderline = prev != null && !prev.isBlank() - && detectSetextUnderline(prev, line) != null; - if (!isSetextUnderline) { - lines.set(i, String.valueOf(line.charAt(0)).repeat(4)); - bt.open(line); - } - } else if (isBlockDelimiter(line)) { - bt.open(line); - } - } - } - - private static boolean isAllSameChar(String line, char c) { - for (int i = 0; i < line.length(); i++) { - if (line.charAt(i) != c) { - return false; - } - } - return true; - } - - private static boolean isOverLongBlockDelimiter(String line) { - return line.length() > 4 && isBlockDelimiter(line); - } - - private static void removeTrailingHeaderEqualsSign(List lines) { - for (int i = 0; i < lines.size(); i++) { - String line = lines.get(i); - Matcher symmetric = SYMMETRIC_HEADING.matcher(line); - if (symmetric.matches()) { - lines.set(i, symmetric.group(1) + " " + symmetric.group(2)); - continue; - } - Matcher section = SECTION_HEADING.matcher(line); - if (section.matches()) { - lines.set(i, section.group(1) + " " + section.group(2)); - } - } - } - - /** - * Collapses multiple consecutive blank lines into a single blank line. - * Performs the transformation in-place on the input list. - */ - private static void collapseBlankLines(List lines) { - int writeIdx = 0; - int consecutiveBlank = 0; - for (int readIdx = 0; readIdx < lines.size(); readIdx++) { - String line = lines.get(readIdx); - if (line.isBlank()) { - consecutiveBlank++; - if (consecutiveBlank <= 1) { - lines.set(writeIdx++, line); - } - } else { - consecutiveBlank = 0; - lines.set(writeIdx++, line); - } - } - if (writeIdx < lines.size()) { - lines.subList(writeIdx, lines.size()).clear(); - } - } - - private static void ensureSourceDelimiters(List lines) { - List result = new ArrayList<>(lines.size() + 8); - BlockTracker bt = new BlockTracker(); - int i = 0; - while (i < lines.size()) { - String line = lines.get(i); - - if (bt.isOpen()) { - result.add(line); - bt.tryClose(line); - i++; - continue; - } - - if (isBlockDelimiter(line)) { - result.add(line); - bt.open(line); - i++; - continue; - } - - if (SOURCE_BLOCK_ATTR.matcher(line).matches()) { - result.add(line); - i++; - if (i < lines.size()) { - String next = lines.get(i); - if (isBlockDelimiter(next)) { - result.add(next); - bt.open(next); - i++; - } else if (!next.isBlank() && !next.startsWith("[")) { - result.add("----"); - while (i < lines.size() && !lines.get(i).isBlank()) { - result.add(lines.get(i)); - i++; - } - result.add("----"); - } - } - continue; - } - - result.add(line); - i++; - } - lines.clear(); - lines.addAll(result); - } - - private static void ensureHeadingBlankLines(List lines) { - List result = new ArrayList<>(lines.size() + 8); - BlockTracker bt = new BlockTracker(); - - for (int i = 0; i < lines.size(); i++) { - String line = lines.get(i); - - if (bt.isOpen()) { - result.add(line); - bt.tryClose(line); - continue; - } - if (isBlockDelimiter(line)) { - result.add(line); - bt.open(line); - continue; - } - - if (SECTION_HEADING.matcher(line).matches()) { - if (!result.isEmpty() && !result.get(result.size() - 1).isBlank()) { - result.add(""); - } - result.add(line); - if (i + 1 < lines.size() && !lines.get(i + 1).isBlank()) { - result.add(""); - } - } else { - result.add(line); - } - } - lines.clear(); - lines.addAll(result); - } - - private static void removeTrailingWhitespace(List lines) { - for (int i = 0; i < lines.size(); i++) { - lines.set(i, lines.get(i).stripTrailing()); - } - } - - private void applyLineTransformations(List lines) { - BlockTracker bt = new BlockTracker(); - for (int i = 0; i < lines.size(); i++) { - String line = lines.get(i); - if (bt.isOpen()) { - bt.tryClose(line); - } else if (isBlockDelimiter(line)) { - bt.open(line); - } else { - if (config.isTitleCase()) { - line = titleCaseLine(line); - } - if (config.isNormalizeListBullets() && line.startsWith("- ")) { - line = "* " + line.substring(2); - } - if (config.isNormalizeOrderedListMarkers()) { - line = normalizeOrderedListMarker(line); - } - lines.set(i, line); - } - } - } - - private static String normalizeOrderedListMarker(String line) { - if (line.isEmpty() || line.charAt(0) < '0' || line.charAt(0) > '9') - return line; - int i = 1; - while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') - i++; - if (i + 1 >= line.length() || line.charAt(i) != '.') - return line; - char sep = line.charAt(i + 1); - if (sep != ' ' && sep != '\t') - return line; - return ". " + line.substring(i + 2); - } - - private static String titleCaseLine(String line) { - Matcher m = SECTION_HEADING.matcher(line); - if (m.matches()) { - return m.group(1) + " " + toTitleCase(m.group(2)); - } - if (line.length() > 1 && line.charAt(0) == '.' && line.charAt(1) != '.' && line.charAt(1) != ' ') { - return "." + toTitleCase(line.substring(1)); - } - return line; - } - - private static String toTitleCase(String text) { - String[] words = text.split(" +", -1); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < words.length; i++) { - if (i > 0) { - sb.append(' '); - } - boolean forceCapitalize = (i == 0) || (i == words.length - 1); - sb.append(capitalizeWordForTitle(words[i], forceCapitalize)); - } - return sb.toString(); - } - - private static String capitalizeWordForTitle(String word, boolean forceCapitalize) { - if (word.isEmpty()) { - return word; - } - if (word.contains("{") || word.contains("`") || word.contains("[")) { - return word; - } - int colonIdx = word.indexOf(':'); - if (colonIdx > 0 && colonIdx < word.length() - 1) { - return word; - } - int firstLetter = -1; - for (int i = 0; i < word.length(); i++) { - if (Character.isLetter(word.charAt(i))) { - firstLetter = i; - break; - } - } - if (firstLetter < 0) { - return word; - } - StringBuilder coreBuilder = new StringBuilder(); - for (int i = firstLetter; i < word.length(); i++) { - char c = word.charAt(i); - if (Character.isLetter(c)) { - coreBuilder.append(Character.toLowerCase(c)); - } - } - String core = coreBuilder.toString(); - if (!forceCapitalize && TITLE_CASE_LOWERCASE.contains(core)) { - return word.toLowerCase(Locale.ROOT); - } - return word.substring(0, firstLetter) - + Character.toUpperCase(word.charAt(firstLetter)) - + word.substring(firstLetter + 1); - } - - private static void applySentencePerLine(List lines) { - List result = new ArrayList<>(lines.size()); - List paragraphBuffer = new ArrayList<>(); - BlockTracker bt = new BlockTracker(); - - for (int i = 0; i < lines.size(); i++) { - String line = lines.get(i); - - if (bt.isOpen()) { - result.add(line); - bt.tryClose(line); - continue; - } - - if (isBlockDelimiter(line)) { - flushParagraph(paragraphBuffer, result); - result.add(line); - bt.open(line); - continue; - } - - if (i + 1 < lines.size() && detectSetextUnderline(line, lines.get(i + 1)) != null) { - flushParagraph(paragraphBuffer, result); - result.add(line); - result.add(lines.get(i + 1)); - i++; - continue; - } - - if (line.isBlank() || isSpecialLine(line)) { - flushParagraph(paragraphBuffer, result); - result.add(line); - continue; - } - - paragraphBuffer.add(line); - } - - flushParagraph(paragraphBuffer, result); - lines.clear(); - lines.addAll(result); - } - - private static void flushParagraph(List buffer, List result) { - if (buffer.isEmpty()) { - return; - } - String joined = MULTI_WHITESPACE.matcher(String.join(" ", buffer)).replaceAll(" ").trim(); - result.addAll(splitIntoSentences(joined)); - buffer.clear(); - } - - private static boolean isBlockDelimiter(String line) { - int len = line.length(); - if (len < 4) - return false; - char c = line.charAt(0); - if (BLOCK_DELIMITER_CHARS.indexOf(c) < 0) - return false; - for (int i = 1; i < len; i++) { - if (line.charAt(i) != c) - return false; - } - return true; - } - - private static boolean isSpecialLine(String line) { - if (line.isEmpty()) { - return false; - } - char first = line.charAt(0); - if (first == '=' || first == '[' || first == '|' || first == ' ' || first == '\t') { - return true; - } - if (line.startsWith("//") || line.startsWith("<<<") || line.equals("'''") || line.equals("+")) { - return true; - } - if (first == ':' && line.length() > 1 && line.charAt(1) != ':') { - return true; - } - if (first == '.' || first == '*' || first == '-') { - if (line.length() > 1 && line.charAt(1) != first && line.charAt(1) != ' ') { - if (first == '.') { - return true; // Block title (.Title) - } - } - int i = 1; - while (i < line.length() && line.charAt(i) == first) { - i++; - } - if (i == line.length() && i >= 3) { - return true; // Horizontal rule (--- or ***) - } - return i < line.length() && line.charAt(i) == ' '; - } - if (Character.isDigit(first)) { - int i = 1; - while (i < line.length() && Character.isDigit(line.charAt(i))) { - i++; - } - return i + 1 < line.length() && line.charAt(i) == '.' - && (line.charAt(i + 1) == ' ' || line.charAt(i + 1) == '\t'); - } - return isBlockMacroOrTerm(line); - } - - private static boolean isBlockMacroOrTerm(String line) { - int len = line.length(); - int i = 0; - while (i < len) { - char c = line.charAt(i); - if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || (c >= '0' && c <= '9')) { - i++; - } else { - break; - } - } - return i > 0 && i + 1 < len && line.charAt(i) == ':' && line.charAt(i + 1) == ':'; - } - - private static List splitIntoSentences(String text) { - if (text.isEmpty()) { - return Collections.emptyList(); - } - - List sentences = new ArrayList<>(); - int start = 0; - int i = 0; - - while (i < text.length()) { - char c = text.charAt(i); - - if (c == '.' || c == '!' || c == '?') { - - if (c == '.' && i + 1 < text.length() && text.charAt(i + 1) == '.') { - i++; - while (i < text.length() && text.charAt(i) == '.') { - i++; - } - continue; - } - - if (c == '.' && isAbbreviationContext(text, i)) { - i++; - continue; - } - - int j = i + 1; - while (j < text.length() && isSentenceClosingChar(text.charAt(j))) { - j++; - } - - if (j >= text.length()) { - i = j; - continue; - } - - if (Character.isWhitespace(text.charAt(j))) { - int k = j; - while (k < text.length() && Character.isWhitespace(text.charAt(k))) { - k++; - } - if (k >= text.length() || Character.isUpperCase(text.charAt(k)) || Character.isDigit(text.charAt(k))) { - String sentence = text.substring(start, j).trim(); - if (!sentence.isEmpty()) { - sentences.add(sentence); - } - start = k; - i = k; - continue; - } - } - } - - i++; - } - - String remaining = text.substring(start).trim(); - if (!remaining.isEmpty()) { - sentences.add(remaining); - } - return sentences; - } - - private static boolean isAbbreviationContext(String text, int dotPos) { - if (dotPos > 0 && Character.isDigit(text.charAt(dotPos - 1))) { - return true; - } - int wordEnd = dotPos; - int wordStart = wordEnd - 1; - while (wordStart >= 0 && Character.isLetter(text.charAt(wordStart))) { - wordStart--; - } - wordStart++; - if (wordStart >= wordEnd) { - return false; - } - String word = text.substring(wordStart, wordEnd); - if (word.length() == 1) { - return true; // Initials (e.g., A. Smith) - } - return ABBREVIATIONS.contains(word.toLowerCase(Locale.ROOT)); - } - - private static boolean isSentenceClosingChar(char c) { - return c == ')' || c == ']' || c == '"' || c == '\'' - || c == '\u2019' - || c == '\u201D'; - } - - private static final class BlockTracker { - private char delimChar = '\0'; - - boolean isOpen() { - return delimChar != '\0'; - } - - void open(String line) { - delimChar = line.charAt(0); - } - - @Nullable String tryClose(String line) { - if (delimChar != '\0' && line.length() >= 4 && isAllSameChar(line, delimChar)) { - String closed = String.valueOf(delimChar); - delimChar = '\0'; - return closed; - } - return null; - } - } } diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java new file mode 100644 index 0000000000..4fcef6d1e8 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Handles transformations for Asciidoc headings. */ +final class AsciidocHeadingHandler { + private AsciidocHeadingHandler() {} + + // Heading with trailing = signs: == Title == or === Title === + // Captured groups: (1) leading equals, (2) title text (trimmed) + private static final Pattern SYMMETRIC_HEADING = Pattern.compile("^(={1,6})\\s+(.*\\S)\\s+=+\\s*$"); + + // Section heading: = Title or == Title, etc. + // Captured groups: (1) leading equals, (2) trimmed title text + static final Pattern SECTION_HEADING = Pattern.compile("^(={1,6})\\s+(\\S.*?)\\s*$"); + + // ATX heading prefixes for setext -> ATX conversion: ATX_PREFIX[n] = "=".repeat(n+1) + " " + private static final String[] ATX_PREFIX = {"= ", "== ", "=== ", "==== ", "===== ", "====== "}; + + static void normalizeSetextHeadings(List lines) { + BlockTracker bt = new BlockTracker(); + int readIdx = 0; + int writeIdx = 0; + while (readIdx < lines.size()) { + String line = lines.get(readIdx); + if (bt.isOpen()) { + lines.set(writeIdx++, line); + bt.tryClose(line); + readIdx++; + continue; + } + if (AsciidocSupport.isBlockDelimiter(line)) { + lines.set(writeIdx++, line); + bt.open(line); + readIdx++; + continue; + } + if (readIdx + 1 < lines.size()) { + Integer level = AsciidocSupport.detectSetextUnderline(line, lines.get(readIdx + 1)); + if (level != null) { + lines.set(writeIdx++, ATX_PREFIX[level] + line); + readIdx += 2; + continue; + } + } + lines.set(writeIdx++, line); + readIdx++; + } + if (writeIdx < lines.size()) { + lines.subList(writeIdx, lines.size()).clear(); + } + } + + static void removeTrailingHeaderEqualsSign(List lines) { + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + Matcher symmetric = SYMMETRIC_HEADING.matcher(line); + if (symmetric.matches()) { + lines.set(i, symmetric.group(1) + ' ' + symmetric.group(2)); + continue; + } + Matcher section = SECTION_HEADING.matcher(line); + if (section.matches()) { + lines.set(i, section.group(1) + ' ' + section.group(2)); + } + } + } + + static void ensureHeadingBlankLines(List lines) { + List result = new ArrayList<>(lines.size() + 8); + BlockTracker bt = new BlockTracker(); + + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + + if (bt.isOpen()) { + result.add(line); + bt.tryClose(line); + continue; + } + if (AsciidocSupport.isBlockDelimiter(line)) { + result.add(line); + bt.open(line); + continue; + } + + if (SECTION_HEADING.matcher(line).matches()) { + if (!result.isEmpty() && !result.get(result.size() - 1).isBlank()) { + result.add(""); + } + result.add(line); + if (i + 1 < lines.size() && !lines.get(i + 1).isBlank()) { + result.add(""); + } + } else { + result.add(line); + } + } + lines.clear(); + lines.addAll(result); + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java new file mode 100644 index 0000000000..498d9f87e9 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java @@ -0,0 +1,137 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.IntStream; + +/** Handles line-level transformations for Asciidoc (title case, lists). */ +final class AsciidocLineHandler { + private static final Pattern MULTIPLE_SPACES = Pattern.compile(" +"); + + private AsciidocLineHandler() {} + + // Words lowercased in title case (articles, conjunctions, short prepositions) + private static final Set TITLE_CASE_LOWERCASE = Set.of( + "a", "an", "the", "and", "but", "or", "nor", "for", "yet", "so", "at", "by", "in", "of", + "on", "to", "up", "as", "off", "out", "per", "via", "from", "with"); + + static void applyLineTransformations(List lines, AsciidocFormatterConfig config) { + BlockTracker bt = new BlockTracker(); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (bt.isOpen()) { + bt.tryClose(line); + } else if (AsciidocSupport.isBlockDelimiter(line)) { + bt.open(line); + } else { + if (config.isTitleCase()) { + line = titleCaseLine(line); + } + if (config.isNormalizeListBullets() && line.startsWith("- ")) { + line = "* " + line.substring(2); + } + if (config.isNormalizeOrderedListMarkers()) { + line = normalizeOrderedListMarker(line); + } + lines.set(i, line); + } + } + } + + private static String normalizeOrderedListMarker(String line) { + if (line.isEmpty() || line.charAt(0) < '0' || line.charAt(0) > '9') { + return line; + } + int i = 1; + while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') { + i++; + } + if (i + 1 >= line.length() || line.charAt(i) != '.') { + return line; + } + char sep = line.charAt(i + 1); + if (sep != ' ' && sep != '\t') { + return line; + } + return ". " + line.substring(i + 2); + } + + private static String titleCaseLine(String line) { + Matcher matcher = AsciidocHeadingHandler.SECTION_HEADING.matcher(line); + if (matcher.matches()) { + return matcher.group(1) + ' ' + toTitleCase(matcher.group(2)); + } + if (line.length() > 1 + && line.charAt(0) == '.' + && line.charAt(1) != '.' + && line.charAt(1) != ' ') { + return '.' + toTitleCase(line.substring(1)); + } + return line; + } + + private static String toTitleCase(CharSequence text) { + String[] words = MULTIPLE_SPACES.split(text, -1); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < words.length; i++) { + if (i > 0) { + sb.append(' '); + } + boolean forceCapitalize = i == 0 || i == words.length - 1; + sb.append(capitalizeWordForTitle(words[i], forceCapitalize)); + } + return sb.toString(); + } + + private static String capitalizeWordForTitle(String word, boolean forceCapitalize) { + if (word.isEmpty()) { + return word; + } + if (word.contains("{") || word.contains("`") || word.contains("[")) { + return word; + } + int colonIdx = word.indexOf(':'); + if (colonIdx > 0 && colonIdx < word.length() - 1) { + return word; + } + int firstLetter = IntStream.range(0, word.length()) + .filter(i -> Character.isLetter(word.charAt(i))) + .findFirst() + .orElse(-1); + if (firstLetter < 0) { + return word; + } + StringBuilder coreBuilder = new StringBuilder(); + for (int i = firstLetter; i < word.length(); i++) { + char c = word.charAt(i); + if (Character.isLetter(c)) { + coreBuilder.append(Character.toLowerCase(c)); + } + } + String core = coreBuilder.toString(); + if (!forceCapitalize && TITLE_CASE_LOWERCASE.contains(core)) { + return word.toLowerCase(Locale.ROOT); + } + return word.substring(0, firstLetter) + + Character.toUpperCase(word.charAt(firstLetter)) + + word.substring(firstLetter + 1); + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java new file mode 100644 index 0000000000..d198234276 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java @@ -0,0 +1,178 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** Handles splitting text into one sentence per line. */ +final class AsciidocSentenceHandler { + private AsciidocSentenceHandler() {} + + // Known abbreviations that end with a period but do not end a sentence + private static final Set ABBREVIATIONS = Set.of( + "mr", "mrs", "ms", "dr", "prof", "sr", "jr", + "vs", "etc", "approx", "dept", "fig", "no", "vol", + "ch", "sec", "ref", "rev", "st", "mt", "ft", + "ave", "blvd", "rd", "pp", "al", "ed", "eds", + "corp", "inc", "ltd", "llc", + "jan", "feb", "mar", "apr", "jun", "jul", + "aug", "sep", "sept", "oct", "nov", "dec", + // German abbreviations + "bspw", "bzw", "bzgl", "ca", "evtl", "exkl", "inkl", "sog"); + + static void applySentencePerLine(List lines) { + Collection result = new ArrayList<>(lines.size()); + Collection paragraphBuffer = new ArrayList<>(); + BlockTracker bt = new BlockTracker(); + + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + + if (bt.isOpen()) { + result.add(line); + bt.tryClose(line); + continue; + } + + if (AsciidocSupport.isBlockDelimiter(line)) { + flushParagraph(paragraphBuffer, result); + result.add(line); + bt.open(line); + continue; + } + + if (i + 1 < lines.size() && AsciidocSupport.detectSetextUnderline(line, lines.get(i + 1)) != null) { + flushParagraph(paragraphBuffer, result); + result.add(line); + result.add(lines.get(i + 1)); + i++; + continue; + } + + if (line.isBlank() || AsciidocSupport.isSpecialLine(line)) { + flushParagraph(paragraphBuffer, result); + result.add(line); + continue; + } + + paragraphBuffer.add(line); + } + + flushParagraph(paragraphBuffer, result); + lines.clear(); + lines.addAll(result); + } + + private static void flushParagraph(Collection buffer, Collection result) { + if (buffer.isEmpty()) { + return; + } + String joined = AsciidocSupport.MULTI_WHITESPACE.matcher(String.join(" ", buffer)).replaceAll(" ").trim(); + result.addAll(splitIntoSentences(joined)); + buffer.clear(); + } + + private static List splitIntoSentences(String text) { + if (text.isEmpty()) { + return Collections.emptyList(); + } + + List sentences = new ArrayList<>(); + int start = 0; + int i = 0; + + while (i < text.length()) { + char c = text.charAt(i); + + if (c == '.' || c == '!' || c == '?') { + + if (c == '.' && i + 1 < text.length() && text.charAt(i + 1) == '.') { + i++; + while (i < text.length() && text.charAt(i) == '.') { + i++; + } + continue; + } + + if (c == '.' && isAbbreviationContext(text, i)) { + i++; + continue; + } + + int j = i + 1; + while (j < text.length() && isSentenceClosingChar(text.charAt(j))) { + j++; + } + + if (j >= text.length()) { + i = j; + continue; + } + + if (Character.isWhitespace(text.charAt(j))) { + int k = j; + while (k < text.length() && Character.isWhitespace(text.charAt(k))) { + k++; + } + if (k >= text.length() || Character.isUpperCase(text.charAt(k)) || Character.isDigit(text.charAt(k))) { + String sentence = text.substring(start, j).trim(); + if (!sentence.isEmpty()) { + sentences.add(sentence); + } + start = k; + i = k; + continue; + } + } + } + + i++; + } + + String remaining = text.substring(start).trim(); + if (!remaining.isEmpty()) { + sentences.add(remaining); + } + return sentences; + } + + private static boolean isAbbreviationContext(String text, int dotPos) { + if (dotPos > 0 && Character.isDigit(text.charAt(dotPos - 1))) { + return true; + } + int wordStart = dotPos - 1; + while (wordStart >= 0 && Character.isLetter(text.charAt(wordStart))) { + wordStart--; + } + wordStart++; + if (wordStart >= dotPos) { + return false; + } + String word = text.substring(wordStart, dotPos); + return word.length() == 1 || ABBREVIATIONS.contains(word.toLowerCase(Locale.ROOT)); // Initials (e.g., A. Smith) + } + + private static boolean isSentenceClosingChar(char c) { + return c == ')' || c == ']' || c == '"' || c == '\'' + || c == '\u2019' + || c == '\u201D'; + } +} diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java new file mode 100644 index 0000000000..e3893d5f3c --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java @@ -0,0 +1,165 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.IntStream; + +import edu.umd.cs.findbugs.annotations.Nullable; + +/** Shared utilities and constants for Asciidoc formatting. */ +final class AsciidocSupport { + private AsciidocSupport() {} + + private static final String BLOCK_DELIMITER_CHARS = "-=.*_+/"; + + static final Pattern MULTI_WHITESPACE = Pattern.compile("\\s+"); + + static void removeTrailingWhitespace(List lines) { + lines.replaceAll(String::stripTrailing); + } + + static void collapseBlankLines(List lines) { + int writeIdx = 0; + int consecutiveBlank = 0; + for (int readIdx = 0; readIdx < lines.size(); readIdx++) { + String line = lines.get(readIdx); + if (line.isBlank()) { + consecutiveBlank++; + if (consecutiveBlank <= 1) { + lines.set(writeIdx++, line); + } + } else { + consecutiveBlank = 0; + lines.set(writeIdx++, line); + } + } + if (writeIdx < lines.size()) { + lines.subList(writeIdx, lines.size()).clear(); + } + } + + static boolean isBlockDelimiter(CharSequence line) { + int len = line.length(); + if (len < 4) { + return false; + } + char c = line.charAt(0); + return BLOCK_DELIMITER_CHARS.indexOf(c) >= 0 && IntStream.range(1, len).noneMatch(i -> line.charAt(i) != c); + } + + static boolean isAllSameChar(CharSequence line, char c) { + return IntStream.range(0, line.length()).noneMatch(i -> line.charAt(i) != c); + } + + @Nullable static Integer detectSetextUnderline(String titleCandidate, CharSequence underlineLine) { + if (titleCandidate.isEmpty()) { + return null; + } + char first = titleCandidate.charAt(0); + if (first == '=' || first == '[' || first == '.' || first == ':' + || first == '*' || first == '-' || first == '|' || first == '+' + || titleCandidate.startsWith("//")) { + return null; + } + if (underlineLine.isEmpty()) { + return null; + } + char underlineChar = underlineLine.charAt(0); + int level; + switch (underlineChar) { + case '=': + level = 0; + break; + case '-': + level = 1; + break; + case '~': + level = 2; + break; + case '^': + level = 3; + break; + case '+': + level = 4; + break; + default: + return null; + } + for (int j = 1; j < underlineLine.length(); j++) { + if (underlineLine.charAt(j) != underlineChar) { + return null; + } + } + if (underlineLine.length() < titleCandidate.length()) { + return null; + } + return level; + } + + static boolean isSpecialLine(String line) { + if (line.isEmpty()) { + return false; + } + char first = line.charAt(0); + if (first == '=' || first == '[' || first == '|' || first == ' ' || first == '\t') { + return true; + } + if (line.startsWith("//") || line.startsWith("<<<") || "'''".equals(line) || "+".equals(line)) { + return true; + } + if (first == ':' && line.length() > 1 && line.charAt(1) != ':') { + return true; + } + if (first == '.' || first == '*' || first == '-') { + if (line.length() > 1 && line.charAt(1) != first && line.charAt(1) != ' ') { + if (first == '.') { + return true; // Block title (.Title) + } + } + int i = 1; + while (i < line.length() && line.charAt(i) == first) { + i++; + } + return i == line.length() && i >= 3 || i < line.length() && line.charAt(i) == ' '; // Horizontal rule (--- or ***) + } + if (Character.isDigit(first)) { + int i = 1; + while (i < line.length() && Character.isDigit(line.charAt(i))) { + i++; + } + return i + 1 < line.length() && line.charAt(i) == '.' + && (line.charAt(i + 1) == ' ' || line.charAt(i + 1) == '\t'); + } + return isBlockMacroOrTerm(line); + } + + private static boolean isBlockMacroOrTerm(CharSequence line) { + int len = line.length(); + int i = 0; + while (i < len) { + char c = line.charAt(i); + if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' || c >= '0' && c <= '9') { + i++; + } else { + break; + } + } + return i > 0 && i + 1 < len && line.charAt(i) == ':' && line.charAt(i + 1) == ':'; + } + +} diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java new file mode 100644 index 0000000000..c77fac8355 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import edu.umd.cs.findbugs.annotations.Nullable; + +class BlockTracker { + private char delimChar = '\0'; + + boolean isOpen() { + return delimChar != '\0'; + } + + void open(CharSequence line) { + delimChar = line.charAt(0); + } + + @Nullable String tryClose(CharSequence line) { + if (delimChar != '\0' && line.length() >= 4 && AsciidocSupport.isAllSameChar(line, delimChar)) { + String closed = String.valueOf(delimChar); + delimChar = '\0'; + return closed; + } + return null; + } +} From e8115cb7c4f53e115ba863620fc24c2a087668b9 Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 15:04:38 +0200 Subject: [PATCH 10/16] Convert procedural code to object-oriented style --- .../asciidoc/AsciidocBlockHandler.java | 10 +++++++--- .../asciidoc/AsciidocFormatterFunc.java | 19 ++++++++++++------- .../asciidoc/AsciidocHeadingHandler.java | 12 ++++++++---- .../asciidoc/AsciidocLineHandler.java | 8 ++++++-- .../asciidoc/AsciidocSentenceHandler.java | 8 ++++++-- 5 files changed, 39 insertions(+), 18 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java index 1ffee37309..1b5012ea5f 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java @@ -22,12 +22,16 @@ /** Handles transformations for Asciidoc blocks (delimiters, source blocks). */ final class AsciidocBlockHandler { - private AsciidocBlockHandler() {} + private final List lines; + + AsciidocBlockHandler(List lines) { + this.lines = lines; + } // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], [source#id,java], etc. private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%#].*"); - static void normalizeBlockDelimiters(List lines) { + void normalizeBlockDelimiters() { BlockTracker bt = new BlockTracker(); for (int i = 0; i < lines.size(); i++) { @@ -55,7 +59,7 @@ private static boolean isOverLongBlockDelimiter(CharSequence line) { return line.length() > 4 && AsciidocSupport.isBlockDelimiter(line); } - static void ensureSourceDelimiters(List lines) { + void ensureSourceDelimiters() { Collection result = new ArrayList<>(lines.size() + 8); BlockTracker bt = new BlockTracker(); int i = 0; diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index dcfd782867..0b64030d1b 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -41,6 +41,11 @@ public String apply(@NonNull String input) throws Exception { // Use \R to match any line break (LF, CRLF, CR) and avoid multiple replacements List lines = new ArrayList<>(Arrays.asList(Pattern.compile("\\R").split(input, -1))); + AsciidocBlockHandler blockHandler = new AsciidocBlockHandler(lines); + AsciidocHeadingHandler headingHandler = new AsciidocHeadingHandler(lines); + AsciidocLineHandler lineHandler = new AsciidocLineHandler(lines); + AsciidocSentenceHandler sentenceHandler = new AsciidocSentenceHandler(lines); + // Ordering constraints: // removeTrailingWhitespace before collapseConsecutiveBlankLines // - whitespace-only lines must be emptied before they can be collapsed. @@ -50,28 +55,28 @@ public String apply(@NonNull String input) throws Exception { AsciidocSupport.removeTrailingWhitespace(lines); } if (config.isEnsureSourceDelimiters()) { - AsciidocBlockHandler.ensureSourceDelimiters(lines); + blockHandler.ensureSourceDelimiters(); } if (config.isNormalizeSetextHeadings()) { - AsciidocHeadingHandler.normalizeSetextHeadings(lines); + headingHandler.normalizeSetextHeadings(); } if (config.isNormalizeBlockDelimiters()) { - AsciidocBlockHandler.normalizeBlockDelimiters(lines); + blockHandler.normalizeBlockDelimiters(); } if (config.isRemoveTrailingHeaderEqualsSign()) { - AsciidocHeadingHandler.removeTrailingHeaderEqualsSign(lines); + headingHandler.removeTrailingHeaderEqualsSign(); } // Combine simple line-by-line transforms into a single in-place pass if (config.isTitleCase() || config.isNormalizeListBullets() || config.isNormalizeOrderedListMarkers()) { - AsciidocLineHandler.applyLineTransformations(lines, config); + lineHandler.applyLineTransformations(config); } if (config.isEnsureHeadingBlankLines()) { - AsciidocHeadingHandler.ensureHeadingBlankLines(lines); + headingHandler.ensureHeadingBlankLines(); } if (config.isOneSentencePerLine()) { - AsciidocSentenceHandler.applySentencePerLine(lines); + sentenceHandler.applySentencePerLine(); } if (config.isCollapseConsecutiveBlankLines()) { AsciidocSupport.collapseBlankLines(lines); diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java index 4fcef6d1e8..b4072c514a 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java @@ -22,7 +22,11 @@ /** Handles transformations for Asciidoc headings. */ final class AsciidocHeadingHandler { - private AsciidocHeadingHandler() {} + private final List lines; + + AsciidocHeadingHandler(List lines) { + this.lines = lines; + } // Heading with trailing = signs: == Title == or === Title === // Captured groups: (1) leading equals, (2) title text (trimmed) @@ -35,7 +39,7 @@ private AsciidocHeadingHandler() {} // ATX heading prefixes for setext -> ATX conversion: ATX_PREFIX[n] = "=".repeat(n+1) + " " private static final String[] ATX_PREFIX = {"= ", "== ", "=== ", "==== ", "===== ", "====== "}; - static void normalizeSetextHeadings(List lines) { + void normalizeSetextHeadings() { BlockTracker bt = new BlockTracker(); int readIdx = 0; int writeIdx = 0; @@ -69,7 +73,7 @@ static void normalizeSetextHeadings(List lines) { } } - static void removeTrailingHeaderEqualsSign(List lines) { + void removeTrailingHeaderEqualsSign() { for (int i = 0; i < lines.size(); i++) { String line = lines.get(i); Matcher symmetric = SYMMETRIC_HEADING.matcher(line); @@ -84,7 +88,7 @@ static void removeTrailingHeaderEqualsSign(List lines) { } } - static void ensureHeadingBlankLines(List lines) { + void ensureHeadingBlankLines() { List result = new ArrayList<>(lines.size() + 8); BlockTracker bt = new BlockTracker(); diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java index 498d9f87e9..2169afd391 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java @@ -26,14 +26,18 @@ final class AsciidocLineHandler { private static final Pattern MULTIPLE_SPACES = Pattern.compile(" +"); - private AsciidocLineHandler() {} + private final List lines; + + AsciidocLineHandler(List lines) { + this.lines = lines; + } // Words lowercased in title case (articles, conjunctions, short prepositions) private static final Set TITLE_CASE_LOWERCASE = Set.of( "a", "an", "the", "and", "but", "or", "nor", "for", "yet", "so", "at", "by", "in", "of", "on", "to", "up", "as", "off", "out", "per", "via", "from", "with"); - static void applyLineTransformations(List lines, AsciidocFormatterConfig config) { + void applyLineTransformations(AsciidocFormatterConfig config) { BlockTracker bt = new BlockTracker(); for (int i = 0; i < lines.size(); i++) { String line = lines.get(i); diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java index d198234276..487af3ef79 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java @@ -24,7 +24,11 @@ /** Handles splitting text into one sentence per line. */ final class AsciidocSentenceHandler { - private AsciidocSentenceHandler() {} + private final List lines; + + AsciidocSentenceHandler(List lines) { + this.lines = lines; + } // Known abbreviations that end with a period but do not end a sentence private static final Set ABBREVIATIONS = Set.of( @@ -38,7 +42,7 @@ private AsciidocSentenceHandler() {} // German abbreviations "bspw", "bzw", "bzgl", "ca", "evtl", "exkl", "inkl", "sog"); - static void applySentencePerLine(List lines) { + void applySentencePerLine() { Collection result = new ArrayList<>(lines.size()); Collection paragraphBuffer = new ArrayList<>(); BlockTracker bt = new BlockTracker(); From 6b140b1b42bb32183531f0dc3714600a0c025d24 Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 15:34:55 +0200 Subject: [PATCH 11/16] Reorganize and add more tests --- .../asciidoc/AsciidocFormatterFunc.java | 6 +- .../asciidoc/AsciidocSentenceHandler.java | 1 - .../spotless/asciidoc/AsciidocSupport.java | 4 + .../asciidoc/AsciidocBlockHandlerTest.java | 214 ++++ .../asciidoc/AsciidocFormatterFuncTest.java | 948 +----------------- .../asciidoc/AsciidocHeadingHandlerTest.java | 286 ++++++ .../asciidoc/AsciidocLineHandlerTest.java | 323 ++++++ .../asciidoc/AsciidocSentenceHandlerTest.java | 291 ++++++ .../spotless/AsciidocExtensionTest.java | 139 +++ 9 files changed, 1278 insertions(+), 934 deletions(-) create mode 100644 lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandlerTest.java create mode 100644 lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandlerTest.java create mode 100644 lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocLineHandlerTest.java create mode 100644 lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandlerTest.java create mode 100644 plugin-gradle/src/test/java/com/diffplug/gradle/spotless/AsciidocExtensionTest.java diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index 0b64030d1b..625a986e18 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -54,12 +54,12 @@ public String apply(@NonNull String input) throws Exception { if (config.isRemoveTrailingWhitespace()) { AsciidocSupport.removeTrailingWhitespace(lines); } - if (config.isEnsureSourceDelimiters()) { - blockHandler.ensureSourceDelimiters(); - } if (config.isNormalizeSetextHeadings()) { headingHandler.normalizeSetextHeadings(); } + if (config.isEnsureSourceDelimiters()) { + blockHandler.ensureSourceDelimiters(); + } if (config.isNormalizeBlockDelimiters()) { blockHandler.normalizeBlockDelimiters(); } diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java index 487af3ef79..d2b554d2df 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java @@ -39,7 +39,6 @@ final class AsciidocSentenceHandler { "corp", "inc", "ltd", "llc", "jan", "feb", "mar", "apr", "jun", "jul", "aug", "sep", "sept", "oct", "nov", "dec", - // German abbreviations "bspw", "bzw", "bzgl", "ca", "evtl", "exkl", "inkl", "sog"); void applySentencePerLine() { diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java index e3893d5f3c..d05fe2ff1d 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java @@ -131,6 +131,10 @@ static boolean isSpecialLine(String line) { return true; // Block title (.Title) } } + // Treat list items as special lines + if (line.length() > 1 && line.charAt(1) == ' ') { + return true; + } int i = 1; while (i < line.length() && line.charAt(i) == first) { i++; diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandlerTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandlerTest.java new file mode 100644 index 0000000000..2eaa155543 --- /dev/null +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandlerTest.java @@ -0,0 +1,214 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +class AsciidocBlockHandlerTest { + + private static AsciidocFormatterFunc funcWith(Consumer customizer) { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + customizer.accept(cfg); + return new AsciidocFormatterFunc(cfg); + } + + private static AsciidocFormatterFunc funcDelimiters() { + return funcWith(cfg -> cfg.setNormalizeBlockDelimiters(true)); + } + + private static AsciidocFormatterFunc funcSourceDelimiters() { + return funcWith(cfg -> cfg.setEnsureSourceDelimiters(true)); + } + + private static String apply(AsciidocFormatterFunc f, String input) { + try { + return f.apply(input); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void shortensLongDashDelimiter() { + assertThat(apply(funcDelimiters(), "--------\ncode\n--------")) + .isEqualTo("----\ncode\n----"); + } + + @Test + void shortensLongEqualsDelimiter() { + assertThat(apply(funcDelimiters(), "========\ncontent\n========")) + .isEqualTo("====\ncontent\n===="); + } + + @Test + void shortensLongDotDelimiter() { + assertThat(apply(funcDelimiters(), "........\nliteral\n........")) + .isEqualTo("....\nliteral\n...."); + } + + @Test + void shortensLongStarDelimiter() { + assertThat(apply(funcDelimiters(), "********\nsidebar\n********")) + .isEqualTo("****\nsidebar\n****"); + } + + @Test + void shortensLongUnderscoreDelimiter() { + assertThat(apply(funcDelimiters(), "________\nquote\n________")) + .isEqualTo("____\nquote\n____"); + } + + @Test + void shortensLongPlusDelimiter() { + assertThat(apply(funcDelimiters(), "++++++++\npass\n++++++++")) + .isEqualTo("++++\npass\n++++"); + } + + @Test + void shortensLongSlashDelimiter() { + assertThat(apply(funcDelimiters(), "////////\ncomment\n////////")) + .isEqualTo("////\ncomment\n////"); + } + + @Test + void leavesMinimalDelimiterUnchanged() { + String input = "----\ncode\n----"; + assertThat(apply(funcDelimiters(), input)).isEqualTo(input); + } + + @Test + void doesNotShortenSetextHeadingUnderline() { + // ============== is a setext heading underline (preceded by a title), + // not a block delimiter, so it must not be shortened. + String input = "Document Title\n=============="; + assertThat(apply(funcDelimiters(), input)).isEqualTo(input); + } + + @Test + void doesNotShortenTildeSetextUnderline() { + // ~ is not a block-delimiter character, so ~~~~~~~ is always a setext underline. + String input = "Subsection\n~~~~~~~~~~"; + assertThat(apply(funcDelimiters(), input)).isEqualTo(input); + } + + @Test + void blockDelimiterNormalizationIsIdempotent() throws Exception { + String input = "--------\ncode\n--------\n\n========\nblock\n========"; + String once = apply(funcDelimiters(), input); + String twice = apply(funcDelimiters(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void sourceBlockWithoutDelimiterGetsWrapped() { + String input = "[source,java]\npublic void foo() {}"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source,java]\n----\npublic void foo() {}\n----"); + } + + @Test + void sourceBlockAlreadyDelimitedLeftUnchanged() { + String input = "[source,java]\n----\npublic void foo() {}\n----"; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } + + @Test + void listingBlockWithoutDelimiterGetsWrapped() { + String input = "[listing]\nsome literal text"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[listing]\n----\nsome literal text\n----"); + } + + @Test + void multiLineSourceBlockWrapped() { + String input = "[source,yaml]\nkey: value\nother: data"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source,yaml]\n----\nkey: value\nother: data\n----"); + } + + @Test + void sourceBlockFollowedByBlankLineNotWrapped() { + // blank line after the attribute means no content to wrap + String input = "[source,java]\n\nsome text"; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } + + @Test + void sourceBlockFollowedByAnotherAttributeNotWrapped() { + // next line is another block attribute; leave it alone + String input = "[source,java]\n[%linenums]\n----\ncode\n----"; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } + + @Test + void sourceBlockWithLanguageVariantsWrapped() { + String input = "[source, json]\n{\"key\": \"value\"}"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source, json]\n----\n{\"key\": \"value\"}\n----"); + } + + @Test + void sourceWithPercentOptionWrapped() { + String input = "[source%autofit,java]\npublic class Foo {}"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source%autofit,java]\n----\npublic class Foo {}\n----"); + } + + @Test + void sourceBlockInsideExistingDelimitedBlockLeftAlone() { + // [source] inside ==== must not be touched because we're inside a block + String input = "====\n[source,java]\ncode\n===="; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } + + @Test + void ensureSourceDelimitersIsIdempotent() throws Exception { + String input = "[source,java]\npublic void foo() {}\n\n[source,yaml]\nkey: value"; + String once = apply(funcSourceDelimiters(), input); + String twice = apply(funcSourceDelimiters(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void overLongDelimiterRecognizedAsExistingDelimiter() { + // "--------" (over-long) counts as an existing delimiter — we don't add another ---- + String input = "[source,java]\n--------\ncode\n--------"; + assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); + } + + @Test + void sourceBlockWithIdShorthandGetsWrapped() { + // [source#id,lang] uses AsciiDoc shorthand — must be recognized and wrapped + String input = "[source#intro,java]\npublic void foo() {}"; + assertThat(apply(funcSourceDelimiters(), input)) + .isEqualTo("[source#intro,java]\n----\npublic void foo() {}\n----"); + } +} diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java index 1651ab8e4a..3a3d8fca70 100644 --- a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java @@ -17,949 +17,37 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.util.function.Consumer; - import org.junit.jupiter.api.Test; class AsciidocFormatterFuncTest { - /** Returns a formatter with every feature disabled, then applies {@code customizer}. */ - private static AsciidocFormatterFunc funcWith(Consumer customizer) { - AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); - cfg.setNormalizeSetextHeadings(false); - cfg.setCollapseConsecutiveBlankLines(false); - cfg.setOneSentencePerLine(false); - cfg.setNormalizeBlockDelimiters(false); - cfg.setRemoveTrailingHeaderEqualsSign(false); - cfg.setTitleCase(false); - cfg.setRemoveTrailingWhitespace(false); - cfg.setNormalizeListBullets(false); - cfg.setNormalizeOrderedListMarkers(false); - cfg.setEnsureHeadingBlankLines(false); - cfg.setEnsureSourceDelimiters(false); - customizer.accept(cfg); - return new AsciidocFormatterFunc(cfg); - } - - private static AsciidocFormatterFunc func(boolean ospl) { - return funcWith(cfg -> cfg.setOneSentencePerLine(ospl)); - } - - private static AsciidocFormatterFunc funcSetext() { - return funcWith(cfg -> cfg.setNormalizeSetextHeadings(true)); - } - - private static AsciidocFormatterFunc funcCollapse() { - return funcWith(cfg -> cfg.setCollapseConsecutiveBlankLines(true)); - } - - private static AsciidocFormatterFunc funcDelimiters() { - return funcWith(cfg -> cfg.setNormalizeBlockDelimiters(true)); - } - - private static AsciidocFormatterFunc funcTrailingEquals() { - return funcWith(cfg -> cfg.setRemoveTrailingHeaderEqualsSign(true)); - } - - private static AsciidocFormatterFunc funcTitleCase() { - return funcWith(cfg -> cfg.setTitleCase(true)); - } - - private static AsciidocFormatterFunc funcTrailingWhitespace() { - return funcWith(cfg -> cfg.setRemoveTrailingWhitespace(true)); - } - - private static AsciidocFormatterFunc funcListBullets() { - return funcWith(cfg -> cfg.setNormalizeListBullets(true)); - } - - private static AsciidocFormatterFunc funcOrderedList() { - return funcWith(cfg -> cfg.setNormalizeOrderedListMarkers(true)); - } - - private static AsciidocFormatterFunc funcHeadingBlanks() { - return funcWith(cfg -> cfg.setEnsureHeadingBlankLines(true)); - } - - private static AsciidocFormatterFunc funcSourceDelimiters() { - return funcWith(cfg -> cfg.setEnsureSourceDelimiters(true)); - } - - private static String apply(AsciidocFormatterFunc f, String input) { - try { - return f.apply(input); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Test - void convertsLevel0SetextHeading() { - assertThat(apply(funcSetext(), "Document Title\n==============")) - .isEqualTo("= Document Title"); - } - - @Test - void convertsLevel1SetextHeading() { - assertThat(apply(funcSetext(), "Section Title\n-------------")) - .isEqualTo("== Section Title"); - } - - @Test - void convertsLevel2SetextHeading() { - assertThat(apply(funcSetext(), "Subsection Title\n~~~~~~~~~~~~~~~~")) - .isEqualTo("=== Subsection Title"); - } - - @Test - void convertsLevel3SetextHeading() { - assertThat(apply(funcSetext(), "Deep Section\n^^^^^^^^^^^^")) - .isEqualTo("==== Deep Section"); - } - - @Test - void convertsLevel4SetextHeading() { - assertThat(apply(funcSetext(), "Deepest Section\n+++++++++++++++")) - .isEqualTo("===== Deepest Section"); - } - - @Test - void convertsAllSetextLevelsInDocument() { - String input = "Document\n========\n\nSection\n-------\n\nSubsection\n~~~~~~~~~~"; - assertThat(apply(funcSetext(), input)).isEqualTo( - "= Document\n\n== Section\n\n=== Subsection"); - } - - @Test - void doesNotConvertWhenUnderlineTooShort() { - // underline shorter than title → not a setext heading - assertThat(apply(funcSetext(), "Long Title Here\n---")) - .isEqualTo("Long Title Here\n---"); - } - - @Test - void doesNotConvertBlockDelimiterAsHeadingUnderline() { - // ---- is a valid block delimiter length but also could be a setext underline; - // the title "Hi" is shorter than "----" (4), so this is ambiguous — - // the rule requires underline.length >= title.length, so "Hi\n----" IS converted. - // A dedicated listing block "----\ncode\n----" must be left alone because there - // is no title line before the first ----. - assertThat(apply(funcSetext(), "----\ncode line\n----")) - .isEqualTo("----\ncode line\n----"); - } - - @Test - void doesNotConvertLineStartingWithEquals() { - // lines starting with = are already atx headings, not setext title candidates - assertThat(apply(funcSetext(), "== Already Atx\n===============")) - .isEqualTo("== Already Atx\n==============="); - } - - @Test - void doesNotConvertLineStartingWithBracket() { - assertThat(apply(funcSetext(), "[source,java]\n=============")) - .isEqualTo("[source,java]\n============="); - } - - @Test - void doesNotConvertLineStartingWithSlash() { - assertThat(apply(funcSetext(), "// comment\n===========")) - .isEqualTo("// comment\n==========="); - } - - @Test - void doesNotConvertClosingBracketBeforeBlockDelimiter() { - // The lone ] line followed by ---- was falsely detected as a setext heading - // (title + dash underline) because normalizeSetextHeadings lacked block tracking. - String input = "[source, json]\n----\nusers: [\n {\n \"id\": \"abc\"\n }\n]\n----"; - assertThat(apply(funcSetext(), input)).isEqualTo(input); - } - - @Test - void setextNormalizationIsIdempotent() throws Exception { - String input = "My Title\n========\n\nA Section\n---------"; - String once = apply(funcSetext(), input); - String twice = apply(funcSetext(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void singleBlankLinePreserved() { - assertThat(apply(funcCollapse(), "A\n\nB")).isEqualTo("A\n\nB"); - } - - @Test - void twoBlankLinesCollapsedToOne() { - assertThat(apply(funcCollapse(), "A\n\n\nB")).isEqualTo("A\n\nB"); - } - - @Test - void threeBlankLinesCollapsedToOne() { - assertThat(apply(funcCollapse(), "A\n\n\n\nB")).isEqualTo("A\n\nB"); - } - - @Test - void noBlankLinesUnchanged() { - assertThat(apply(funcCollapse(), "A\nB\nC")).isEqualTo("A\nB\nC"); - } - - @Test - void multipleGroupsEachCollapsed() { - assertThat(apply(funcCollapse(), "A\n\n\nB\n\n\n\nC")).isEqualTo("A\n\nB\n\nC"); - } - - @Test - void leadingBlankLinesCollapsed() { - assertThat(apply(funcCollapse(), "\n\n\nA")).isEqualTo("\nA"); - } - - @Test - void trailingBlankLinesCollapsed() { - assertThat(apply(funcCollapse(), "A\n\n\n")).isEqualTo("A\n"); - } - - @Test - void collapseIsIdempotent() throws Exception { - String input = "A\n\n\nB\n\n\n\nC"; - String once = apply(funcCollapse(), input); - String twice = apply(funcCollapse(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void splitsTwoSentencesOnOneLine() { - String input = "First sentence. Second sentence."; - assertThat(apply(func(true), input)).isEqualTo( - "First sentence.\nSecond sentence."); - } - - @Test - void splitsExclamationAndQuestion() { - String input = "Watch out! Are you sure? Proceed anyway."; - assertThat(apply(func(true), input)).isEqualTo( - "Watch out!\nAre you sure?\nProceed anyway."); - } - - @Test - void joinsMultiLineParagraphThenSplits() { - String input = "This is a long sentence that\nspans multiple lines. Second sentence."; - assertThat(apply(func(true), input)).isEqualTo( - "This is a long sentence that spans multiple lines.\nSecond sentence."); - } - - @Test - void idempotent() throws Exception { - String input = "First sentence. Second sentence.\nThird sentence."; - AsciidocFormatterFunc f = func(true); - String once = apply(f, input); - String twice = apply(f, once); - assertThat(twice).isEqualTo(once); - } - - @Test - void drAbbreviationIsNotASentenceBoundary() { - String input = "Consult Dr. Smith before proceeding. Then continue."; - assertThat(apply(func(true), input)).isEqualTo( - "Consult Dr. Smith before proceeding.\nThen continue."); - } - - @Test - void initialIsNotASentenceBoundary() { - String input = "The author is A. Smith. He is famous."; - assertThat(apply(func(true), input)).isEqualTo( - "The author is A. Smith.\nHe is famous."); - } - - @Test - void abbreviationFollowedByCapitalIsNotASentenceBoundary() { - String input = "Item etc. And more. Next sentence."; - assertThat(apply(func(true), input)).isEqualTo( - "Item etc. And more.\nNext sentence."); - } - - @Test - void blockTitleIsSpecialLine() { - String input = ".Block Title\nThis is a sentence. This is another."; - assertThat(apply(func(true), input)).isEqualTo( - ".Block Title\nThis is a sentence.\nThis is another."); - } - - @Test - void doesNotSplitInsideEgAbbreviation() { - String input = "Use a tool (e.g. Spotless) for formatting. It helps."; - assertThat(apply(func(true), input)).isEqualTo( - "Use a tool (e.g. Spotless) for formatting.\nIt helps."); - } - - @Test - void doesNotSplitDecimalNumber() { - String input = "The value is 3.14 approximately. Use it wisely."; - assertThat(apply(func(true), input)).isEqualTo( - "The value is 3.14 approximately.\nUse it wisely."); - } - - @Test - void doesNotSplitEllipsis() { - String input = "Well... that is interesting. Next point."; - assertThat(apply(func(true), input)).isEqualTo( - "Well... that is interesting.\nNext point."); - } - - @Test - void doesNotTouchHeadings() { - String input = "== Section Title\n\nParagraph text."; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void doesNotTouchAttributeEntries() { - String input = ":my-attr: some value\n\nParagraph."; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void doesNotTouchBlockAttributes() { - String input = "[source,java]\n----\ncode here\n----\n\nText."; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void doesNotTouchListItems() { - String input = "* First item. Still item.\n* Second item."; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void doesNotReformatInsideListingBlock() { - String input = "----\nFirst sentence. Second sentence.\n----"; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void doesNotReformatInsideExampleBlock() { - String input = "====\nFirst sentence. Second sentence.\n===="; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void pageBreakIsNotJoinedWithAdjacentMacros() { - // toc::[], <<<, and include:: are structural – they must never be accumulated - // into a paragraph and joined into a single line - String input = "toc::[]\n<<<\ninclude::file.adoc[]"; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void pageBreakBetweenParagraphsPassedThrough() { - String input = "First paragraph.\n<<<\nSecond paragraph."; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void includeDirectiveNotJoinedWithParagraph() { - String input = "include::chapter.adoc[]\n\nSome text."; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void tocMacroPassedThrough() { - assertThat(apply(func(true), "toc::[]")).isEqualTo("toc::[]"); - } - - @Test - void horizontalRulePassedThrough() { - assertThat(apply(func(true), "Sentence one.\n'''\nSentence two.")) - .isEqualTo("Sentence one.\n'''\nSentence two."); - } - - @Test - void dashHorizontalRulePassedThrough() { - assertThat(apply(func(true), "Sentence one.\n---\nSentence two.")) - .isEqualTo("Sentence one.\n---\nSentence two."); - } - - @Test - void asteriskHorizontalRulePassedThrough() { - assertThat(apply(func(true), "Sentence one.\n***\nSentence two.")) - .isEqualTo("Sentence one.\n***\nSentence two."); - } - - @Test - void blankLineSeparatesParagraphs() { - String input = "Paragraph one sentence one. Sentence two.\n\nParagraph two."; - assertThat(apply(func(true), input)).isEqualTo( - "Paragraph one sentence one.\nSentence two.\n\nParagraph two."); - } - - @Test - void doesNotMangleSetextHeading() { - String input = "My Section\n----------\n\nParagraph text."; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void shortensLongDashDelimiter() { - assertThat(apply(funcDelimiters(), "--------\ncode\n--------")) - .isEqualTo("----\ncode\n----"); - } - - @Test - void shortensLongEqualsDelimiter() { - assertThat(apply(funcDelimiters(), "========\ncontent\n========")) - .isEqualTo("====\ncontent\n===="); - } - - @Test - void shortensLongDotDelimiter() { - assertThat(apply(funcDelimiters(), "........\nliteral\n........")) - .isEqualTo("....\nliteral\n...."); - } - - @Test - void shortensLongStarDelimiter() { - assertThat(apply(funcDelimiters(), "********\nsidebar\n********")) - .isEqualTo("****\nsidebar\n****"); - } - - @Test - void shortensLongUnderscoreDelimiter() { - assertThat(apply(funcDelimiters(), "________\nquote\n________")) - .isEqualTo("____\nquote\n____"); - } - - @Test - void shortensLongPlusDelimiter() { - assertThat(apply(funcDelimiters(), "++++++++\npass\n++++++++")) - .isEqualTo("++++\npass\n++++"); - } - - @Test - void shortensLongSlashDelimiter() { - assertThat(apply(funcDelimiters(), "////////\ncomment\n////////")) - .isEqualTo("////\ncomment\n////"); - } - - @Test - void leavesMinimalDelimiterUnchanged() { - String input = "----\ncode\n----"; - assertThat(apply(funcDelimiters(), input)).isEqualTo(input); - } - - @Test - void doesNotShortenSetextHeadingUnderline() { - // ============== is a setext heading underline (preceded by a title), - // not a block delimiter, so it must not be shortened. - String input = "Document Title\n=============="; - assertThat(apply(funcDelimiters(), input)).isEqualTo(input); - } - - @Test - void doesNotShortenTildeSetextUnderline() { - // ~ is not a block-delimiter character, so ~~~~~~~ is always a setext underline. - String input = "Subsection\n~~~~~~~~~~"; - assertThat(apply(funcDelimiters(), input)).isEqualTo(input); - } - - @Test - void blockDelimiterNormalizationIsIdempotent() throws Exception { - String input = "--------\ncode\n--------\n\n========\nblock\n========"; - String once = apply(funcDelimiters(), input); - String twice = apply(funcDelimiters(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void removesTrailingEqualsFromH2() { - assertThat(apply(funcTrailingEquals(), "== Section Title ==")) - .isEqualTo("== Section Title"); - } - - @Test - void removesTrailingEqualsFromH3() { - assertThat(apply(funcTrailingEquals(), "=== Subsection ===")) - .isEqualTo("=== Subsection"); - } - - @Test - void removesTrailingEqualsFromH4() { - assertThat(apply(funcTrailingEquals(), "==== Deep ==== Section ====")) - .isEqualTo("==== Deep ==== Section"); - } - - @Test - void removesTrailingEqualsWithTrailingSpaces() { - assertThat(apply(funcTrailingEquals(), "== Title == ")) - .isEqualTo("== Title"); - } - - @Test - void leavesAsymmetricHeadingUnchanged() { - String input = "== Already Asymmetric"; - assertThat(apply(funcTrailingEquals(), input)).isEqualTo(input); - } - - @Test - void leavesNonHeadingLinesUnchanged() { - String input = "Normal paragraph with == signs == inside."; - assertThat(apply(funcTrailingEquals(), input)).isEqualTo(input); - } - - @Test - void removeTrailingEqualsIsIdempotent() throws Exception { - String input = "== Title ==\n=== Sub ==="; - String once = apply(funcTrailingEquals(), input); - String twice = apply(funcTrailingEquals(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void singleSentenceReturnedAsIs() { - assertThat(apply(func(true), "Just one sentence.")).isEqualTo("Just one sentence."); - } - - @Test - void lowercaseAfterPeriodIsNotASplit() { - assertThat(apply(func(true), "lowercase follows. not a new sentence. no split here.")) - .isEqualTo("lowercase follows. not a new sentence. no split here."); - } - - @Test - void titleCasesLevel1SectionHeading() { - assertThat(apply(funcTitleCase(), "= examples of title case")) - .isEqualTo("= Examples of Title Case"); - } - - @Test - void titleCaseHandlesWordsWithPunctuation() { - assertThat(apply(funcTitleCase(), "== word, and another")) - .isEqualTo("== Word, and Another"); - } - - @Test - void titleCasesLevel2SectionHeading() { - assertThat(apply(funcTitleCase(), "== the quick brown fox")) - .isEqualTo("== The Quick Brown Fox"); - } - - @Test - void titleCasesDeepSectionHeading() { - assertThat(apply(funcTitleCase(), "==== art of the deal")) - .isEqualTo("==== Art of the Deal"); - } - - @Test - void titleCasesBlockTitle() { - assertThat(apply(funcTitleCase(), ".examples of title case")) - .isEqualTo(".Examples of Title Case"); - } - - @Test - void firstWordAlwaysCapitalizedEvenIfInLowercaseSet() { - // "of" is in the lowercase set but as the first word it must be capitalized - assertThat(apply(funcTitleCase(), "== of mice and men")) - .isEqualTo("== Of Mice and Men"); - } - - @Test - void lastWordAlwaysCapitalized() { - // "the" at the end must be capitalized - assertThat(apply(funcTitleCase(), "== end of the")) - .isEqualTo("== End of The"); - } - - @Test - void articlesLowercasedInMiddle() { - assertThat(apply(funcTitleCase(), "== the cat and the hat")) - .isEqualTo("== The Cat and the Hat"); - } - - @Test - void prepositionLowercasedInMiddle() { - assertThat(apply(funcTitleCase(), "== art of war")) - .isEqualTo("== Art of War"); - } - - @Test - void coordinatingConjunctionLowercased() { - assertThat(apply(funcTitleCase(), "== black or white")) - .isEqualTo("== Black or White"); - } - @Test - void wordWithAttributeReferenceSkipped() { - // {attr} contains special chars — must be left as-is - assertThat(apply(funcTitleCase(), "== {doctitle} overview")) - .isEqualTo("== {doctitle} Overview"); - } - - @Test - void wordWithCodeSpanSkipped() { - assertThat(apply(funcTitleCase(), "== use `code` here")) - .isEqualTo("== Use `code` Here"); - } - - @Test - void wordWithMacroSkipped() { - // link:target[] is an AsciiDoc macro — skip the whole token - assertThat(apply(funcTitleCase(), "== see link:url[] for details")) - .isEqualTo("== See link:url[] for Details"); - } - - @Test - void regularParagraphLineUntouched() { - String input = "this is just regular paragraph text."; - assertThat(apply(funcTitleCase(), input)).isEqualTo(input); - } - - @Test - void alreadyTitleCasedHeadingUnchanged() { - String input = "== Examples of Title Case"; - assertThat(apply(funcTitleCase(), input)).isEqualTo(input); - } - - @Test - void titleCaseIsIdempotent() throws Exception { - String input = "== examples of title case\n\n.a block title with of and the\n\nParagraph."; - String once = apply(funcTitleCase(), input); - String twice = apply(funcTitleCase(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void dotDotLineNotTreatedAsBlockTitle() { - // ..something is not a block title (starts with ..) - String input = "..not a block title"; - assertThat(apply(funcTitleCase(), input)).isEqualTo(input); - } - - @Test - void dotSpaceLineNotTreatedAsBlockTitle() { - // ". item" starts with dot-space — that's an ordered list item, not a block title - String input = ". list item text"; - assertThat(apply(funcTitleCase(), input)).isEqualTo(input); - } - - @Test - void trailingSpacesRemovedFromLine() { - assertThat(apply(funcTrailingWhitespace(), "line with trailing spaces ")) - .isEqualTo("line with trailing spaces"); - } - - @Test - void trailingTabRemovedFromLine() { - assertThat(apply(funcTrailingWhitespace(), "line with tab\t")) - .isEqualTo("line with tab"); - } - - @Test - void lineWithoutTrailingWhitespaceUnchanged() { - String input = "clean line"; - assertThat(apply(funcTrailingWhitespace(), input)).isEqualTo(input); - } - - @Test - void blankLineReducedToEmpty() { - assertThat(apply(funcTrailingWhitespace(), " ")).isEqualTo(""); - } - - @Test - void trailingWhitespaceRemovedFromMultipleLines() { - assertThat(apply(funcTrailingWhitespace(), "first \nsecond\t\nthird ")) - .isEqualTo("first\nsecond\nthird"); - } - - @Test - void removeTrailingWhitespaceIsIdempotent() throws Exception { - String input = "line one \nline two\t"; - String once = apply(funcTrailingWhitespace(), input); - String twice = apply(funcTrailingWhitespace(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void dashListItemConvertedToAsterisk() { - assertThat(apply(funcListBullets(), "- first item")) - .isEqualTo("* first item"); - } - - @Test - void multipleDashItemsAllConverted() { - assertThat(apply(funcListBullets(), "- one\n- two\n- three")) - .isEqualTo("* one\n* two\n* three"); - } - - @Test - void asteriskListItemUnchanged() { - String input = "* existing asterisk item"; - assertThat(apply(funcListBullets(), input)).isEqualTo(input); - } - - @Test - void nestedAsteriskItemsUnchanged() { - String input = "* level one\n** level two\n*** level three"; - assertThat(apply(funcListBullets(), input)).isEqualTo(input); - } - - @Test - void dashInsideCodeBlockUntouched() { - String input = "----\n- not a list item\n----"; - assertThat(apply(funcListBullets(), input)).isEqualTo(input); - } - - @Test - void blockDelimiterDashesNotConvertedToAsterisk() { - // "----" is a block delimiter, not a list item - String input = "----\ncode\n----"; - assertThat(apply(funcListBullets(), input)).isEqualTo(input); - } - - @Test - void listBulletsNormalizationIsIdempotent() throws Exception { - String input = "- one\n- two"; - String once = apply(funcListBullets(), input); - String twice = apply(funcListBullets(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void numberedListItemConvertedToAsciiDocStyle() { - assertThat(apply(funcOrderedList(), "1. First item")) - .isEqualTo(". First item"); - } - - @Test - void largeNumberConvertedToAsciiDocStyle() { - assertThat(apply(funcOrderedList(), "42. Some item")) - .isEqualTo(". Some item"); - } - - @Test - void multipleNumberedItemsAllConverted() { - assertThat(apply(funcOrderedList(), "1. First\n2. Second\n3. Third")) - .isEqualTo(". First\n. Second\n. Third"); - } - - @Test - void asciiDocDotStyleUnchanged() { - String input = ". First\n. Second"; - assertThat(apply(funcOrderedList(), input)).isEqualTo(input); - } - - @Test - void numberedListInsideCodeBlockUntouched() { - String input = "----\n1. not a list item\n----"; - assertThat(apply(funcOrderedList(), input)).isEqualTo(input); - } - - @Test - void decimalNumberNotConvertedToListMarker() { - // "3.14" does not match the "digit(s) dot space" pattern - String input = "Version 3.14 is released."; - assertThat(apply(funcOrderedList(), input)).isEqualTo(input); - } - - @Test - void orderedListNormalizationIsIdempotent() throws Exception { - String input = "1. First\n2. Second\n3. Third"; - String once = apply(funcOrderedList(), input); - String twice = apply(funcOrderedList(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void numberedListWithTabAfterNumberConverted() { - // "1.\titem" must be treated the same as "1. item" - assertThat(apply(funcOrderedList(), "1.\tFirst item")) - .isEqualTo(". First item"); - } - - @Test - void numberedListWithTabNotMangledByOneSentencePerLine() { - // "1.\titem" must be recognised as a list item (special line) so that - // oneSentencePerLine does not join consecutive items into one long line - String input = "1.\tFirst item\n2.\tSecond item\n3.\tThird item"; - assertThat(apply(func(true), input)).isEqualTo(input); - } - - @Test - void tabAfterHeadingMarkerNormalizedToSpace() { - assertThat(apply(funcTrailingEquals(), "===\tNginx")) - .isEqualTo("=== Nginx"); - } - - @Test - void multipleSpacesAfterHeadingMarkerCollapsed() { - assertThat(apply(funcTrailingEquals(), "== Title")) - .isEqualTo("== Title"); - } - - @Test - void blankLineAddedAfterHeading() { - assertThat(apply(funcHeadingBlanks(), "== Section\nContent")) - .isEqualTo("== Section\n\nContent"); - } - - @Test - void blankLineAddedBeforeHeading() { - assertThat(apply(funcHeadingBlanks(), "Content\n== Section")) - .isEqualTo("Content\n\n== Section"); - } - - @Test - void blankLinesAddedBothSides() { - assertThat(apply(funcHeadingBlanks(), "Before\n== Section\nAfter")) - .isEqualTo("Before\n\n== Section\n\nAfter"); - } - - @Test - void noDoubleBlankLineWhenAlreadyPresent() { - String input = "Before\n\n== Section\n\nAfter"; - assertThat(apply(funcHeadingBlanks(), input)).isEqualTo(input); - } - - @Test - void noBlankLineBeforeFirstHeading() { - assertThat(apply(funcHeadingBlanks(), "= Title\nContent")) - .isEqualTo("= Title\n\nContent"); - } - - @Test - void consecutiveHeadingsGetBlankLineBetweenThem() { - assertThat(apply(funcHeadingBlanks(), "== Section A\n=== Subsection")) - .isEqualTo("== Section A\n\n=== Subsection"); - } - - @Test - void headingInsideCodeBlockGetsNoBlankLine() { - // The "== heading" inside ---- must not acquire surrounding blank lines - String input = "----\n== not a real heading\ncontent\n----"; - assertThat(apply(funcHeadingBlanks(), input)).isEqualTo(input); - } - - @Test - void ensureHeadingBlankLinesIsIdempotent() throws Exception { - String input = "Intro\n== Section\nBody text\n=== Sub\nMore"; - String once = apply(funcHeadingBlanks(), input); - String twice = apply(funcHeadingBlanks(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void sourceBlockWithoutDelimiterGetsWrapped() { - String input = "[source,java]\npublic void foo() {}"; - assertThat(apply(funcSourceDelimiters(), input)) - .isEqualTo("[source,java]\n----\npublic void foo() {}\n----"); - } - - @Test - void sourceBlockAlreadyDelimitedLeftUnchanged() { - String input = "[source,java]\n----\npublic void foo() {}\n----"; - assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); - } - - @Test - void listingBlockWithoutDelimiterGetsWrapped() { - String input = "[listing]\nsome literal text"; - assertThat(apply(funcSourceDelimiters(), input)) - .isEqualTo("[listing]\n----\nsome literal text\n----"); - } - - @Test - void multiLineSourceBlockWrapped() { - String input = "[source,yaml]\nkey: value\nother: data"; - assertThat(apply(funcSourceDelimiters(), input)) - .isEqualTo("[source,yaml]\n----\nkey: value\nother: data\n----"); - } + void appliesMultipleFormattingRules() throws Exception { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(true); + cfg.setNormalizeBlockDelimiters(true); + cfg.setTitleCase(true); + cfg.setOneSentencePerLine(true); + cfg.setNormalizeListBullets(true); + cfg.setEnsureSourceDelimiters(true); - @Test - void sourceBlockFollowedByBlankLineNotWrapped() { - // blank line after the attribute means no content to wrap - String input = "[source,java]\n\nsome text"; - assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); - } + AsciidocFormatterFunc func = new AsciidocFormatterFunc(cfg); - @Test - void sourceBlockFollowedByAnotherAttributeNotWrapped() { - // next line is another block attribute; leave it alone - String input = "[source,java]\n[%linenums]\n----\ncode\n----"; - assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); - } + String input = "my title\n========\n\n- list item one. list item two.\n\n[source, java]\npublic void foo() {}"; + String expected = "= My Title\n\n* list item one. list item two.\n\n[source, java]\n----\npublic void foo() {}\n----"; - @Test - void sourceBlockWithLanguageVariantsWrapped() { - String input = "[source, json]\n{\"key\": \"value\"}"; - assertThat(apply(funcSourceDelimiters(), input)) - .isEqualTo("[source, json]\n----\n{\"key\": \"value\"}\n----"); + assertThat(func.apply(input)).isEqualTo(expected); } @Test - void sourceWithPercentOptionWrapped() { - String input = "[source%autofit,java]\npublic class Foo {}"; - assertThat(apply(funcSourceDelimiters(), input)) - .isEqualTo("[source%autofit,java]\n----\npublic class Foo {}\n----"); - } - - @Test - void sourceBlockInsideExistingDelimitedBlockLeftAlone() { - // [source] inside ==== must not be touched because we're inside a block - String input = "====\n[source,java]\ncode\n===="; - assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); - } - - @Test - void ensureSourceDelimitersIsIdempotent() throws Exception { - String input = "[source,java]\npublic void foo() {}\n\n[source,yaml]\nkey: value"; - String once = apply(funcSourceDelimiters(), input); - String twice = apply(funcSourceDelimiters(), once); - assertThat(twice).isEqualTo(once); - } - - @Test - void overLongDelimiterRecognizedAsExistingDelimiter() { - // "--------" (over-long) counts as an existing delimiter — we don't add another ---- - String input = "[source,java]\n--------\ncode\n--------"; - assertThat(apply(funcSourceDelimiters(), input)).isEqualTo(input); - } - - @Test - void sourceBlockWithIdShorthandGetsWrapped() { - // [source#id,lang] uses AsciiDoc shorthand — must be recognized and wrapped - String input = "[source#intro,java]\npublic void foo() {}"; - assertThat(apply(funcSourceDelimiters(), input)) - .isEqualTo("[source#intro,java]\n----\npublic void foo() {}\n----"); - } + void appliesNoFormattingWhenConfigDisabled() throws Exception { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + // All defaults are false - @Test - void setextNormalizationThenHeadingBlankLinesThenTitleCase() { - // Exercises the three ordering-dependent transformations in sequence: - // setext → ATX (normalizeSetextHeadings), then blank-line padding - // (ensureHeadingBlankLines), then title-casing (titleCase). - AsciidocFormatterFunc f = funcWith(cfg -> { - cfg.setNormalizeSetextHeadings(true); - cfg.setEnsureHeadingBlankLines(true); - cfg.setTitleCase(true); - }); - String input = "some text\nmy cool section\n---------------\nsome body"; - assertThat(apply(f, input)) - .isEqualTo("some text\n\n== My Cool Section\n\nsome body"); - } + AsciidocFormatterFunc func = new AsciidocFormatterFunc(cfg); - @Test - void crlfLineEndingsNormalizedToLf() { - // removeTrailingWhitespace strips \r, and join("\n") produces LF-only output - assertThat(apply(funcTrailingWhitespace(), "line one\r\nline two\r\n")) - .isEqualTo("line one\nline two\n"); - } + String input = "some text"; - @Test - void crlfHeadingRecognizedAfterTrailingWhitespaceRemoval() { - // Without removeTrailingWhitespace the \r ends up in the heading text; - // verify that the combination produces correct output. - AsciidocFormatterFunc f = funcWith(cfg -> { - cfg.setRemoveTrailingWhitespace(true); - cfg.setRemoveTrailingHeaderEqualsSign(true); - }); - assertThat(apply(f, "== Title\r\n\r\nBody.\r\n")) - .isEqualTo("== Title\n\nBody.\n"); + assertThat(func.apply(input)).isEqualTo(input); } } diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandlerTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandlerTest.java new file mode 100644 index 0000000000..eeaaa1f206 --- /dev/null +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandlerTest.java @@ -0,0 +1,286 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +class AsciidocHeadingHandlerTest { + + private static AsciidocFormatterFunc funcWith(Consumer customizer) { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + customizer.accept(cfg); + return new AsciidocFormatterFunc(cfg); + } + + private static AsciidocFormatterFunc funcSetext() { + return funcWith(cfg -> cfg.setNormalizeSetextHeadings(true)); + } + + private static AsciidocFormatterFunc funcTrailingEquals() { + return funcWith(cfg -> cfg.setRemoveTrailingHeaderEqualsSign(true)); + } + + private static AsciidocFormatterFunc funcHeadingBlanks() { + return funcWith(cfg -> cfg.setEnsureHeadingBlankLines(true)); + } + + private static String apply(AsciidocFormatterFunc f, String input) { + try { + return f.apply(input); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void convertsLevel0SetextHeading() { + assertThat(apply(funcSetext(), "Document Title\n==============")) + .isEqualTo("= Document Title"); + } + + @Test + void convertsLevel1SetextHeading() { + assertThat(apply(funcSetext(), "Section Title\n-------------")) + .isEqualTo("== Section Title"); + } + + @Test + void convertsLevel2SetextHeading() { + assertThat(apply(funcSetext(), "Subsection Title\n~~~~~~~~~~~~~~~~")) + .isEqualTo("=== Subsection Title"); + } + + @Test + void convertsLevel3SetextHeading() { + assertThat(apply(funcSetext(), "Deep Section\n^^^^^^^^^^^^")) + .isEqualTo("==== Deep Section"); + } + + @Test + void convertsLevel4SetextHeading() { + assertThat(apply(funcSetext(), "Deepest Section\n+++++++++++++++")) + .isEqualTo("===== Deepest Section"); + } + + @Test + void convertsAllSetextLevelsInDocument() { + String input = "Document\n========\n\nSection\n-------\n\nSubsection\n~~~~~~~~~~"; + assertThat(apply(funcSetext(), input)).isEqualTo( + "= Document\n\n== Section\n\n=== Subsection"); + } + + @Test + void doesNotConvertWhenUnderlineTooShort() { + // underline shorter than title → not a setext heading + assertThat(apply(funcSetext(), "Long Title Here\n---")) + .isEqualTo("Long Title Here\n---"); + } + + @Test + void doesNotConvertBlockDelimiterAsHeadingUnderline() { + // ---- is a valid block delimiter length but also could be a setext underline; + // the title "Hi" is shorter than "----" (4), so this is ambiguous — + // the rule requires underline.length >= title.length, so "Hi\n----" IS converted. + // A dedicated listing block "----\ncode\n----" must be left alone because there + // is no title line before the first ----. + assertThat(apply(funcSetext(), "----\ncode line\n----")) + .isEqualTo("----\ncode line\n----"); + } + + @Test + void doesNotConvertLineStartingWithEquals() { + // lines starting with = are already atx headings, not setext title candidates + assertThat(apply(funcSetext(), "== Already Atx\n===============")) + .isEqualTo("== Already Atx\n==============="); + } + + @Test + void doesNotConvertLineStartingWithBracket() { + assertThat(apply(funcSetext(), "[source,java]\n=============")) + .isEqualTo("[source,java]\n============="); + } + + @Test + void doesNotConvertLineStartingWithSlash() { + assertThat(apply(funcSetext(), "// comment\n===========")) + .isEqualTo("// comment\n==========="); + } + + @Test + void doesNotConvertClosingBracketBeforeBlockDelimiter() { + // The lone ] line followed by ---- was falsely detected as a setext heading + // (title + dash underline) because normalizeSetextHeadings lacked block tracking. + String input = "[source, json]\n----\nusers: [\n {\n \"id\": \"abc\"\n }\n]\n----"; + assertThat(apply(funcSetext(), input)).isEqualTo(input); + } + + @Test + void setextNormalizationIsIdempotent() throws Exception { + String input = "My Title\n========\n\nA Section\n---------"; + String once = apply(funcSetext(), input); + String twice = apply(funcSetext(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void removesTrailingEqualsFromH2() { + assertThat(apply(funcTrailingEquals(), "== Section Title ==")) + .isEqualTo("== Section Title"); + } + + @Test + void removesTrailingEqualsFromH3() { + assertThat(apply(funcTrailingEquals(), "=== Subsection ===")) + .isEqualTo("=== Subsection"); + } + + @Test + void removesTrailingEqualsFromH4() { + assertThat(apply(funcTrailingEquals(), "==== Deep ==== Section ====")) + .isEqualTo("==== Deep ==== Section"); + } + + @Test + void removesTrailingEqualsWithTrailingSpaces() { + assertThat(apply(funcTrailingEquals(), "== Title == ")) + .isEqualTo("== Title"); + } + + @Test + void leavesAsymmetricHeadingUnchanged() { + String input = "== Already Asymmetric"; + assertThat(apply(funcTrailingEquals(), input)).isEqualTo(input); + } + + @Test + void leavesNonHeadingLinesUnchanged() { + String input = "Normal paragraph with == signs == inside."; + assertThat(apply(funcTrailingEquals(), input)).isEqualTo(input); + } + + @Test + void removeTrailingEqualsIsIdempotent() throws Exception { + String input = "== Title ==\n=== Sub ==="; + String once = apply(funcTrailingEquals(), input); + String twice = apply(funcTrailingEquals(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void tabAfterHeadingMarkerNormalizedToSpace() { + assertThat(apply(funcTrailingEquals(), "===\tNginx")) + .isEqualTo("=== Nginx"); + } + + @Test + void multipleSpacesAfterHeadingMarkerCollapsed() { + assertThat(apply(funcTrailingEquals(), "== Title")) + .isEqualTo("== Title"); + } + + @Test + void blankLineAddedAfterHeading() { + assertThat(apply(funcHeadingBlanks(), "== Section\nContent")) + .isEqualTo("== Section\n\nContent"); + } + + @Test + void blankLineAddedBeforeHeading() { + assertThat(apply(funcHeadingBlanks(), "Content\n== Section")) + .isEqualTo("Content\n\n== Section"); + } + + @Test + void blankLinesAddedBothSides() { + assertThat(apply(funcHeadingBlanks(), "Before\n== Section\nAfter")) + .isEqualTo("Before\n\n== Section\n\nAfter"); + } + + @Test + void noDoubleBlankLineWhenAlreadyPresent() { + String input = "Before\n\n== Section\n\nAfter"; + assertThat(apply(funcHeadingBlanks(), input)).isEqualTo(input); + } + + @Test + void noBlankLineBeforeFirstHeading() { + assertThat(apply(funcHeadingBlanks(), "= Title\nContent")) + .isEqualTo("= Title\n\nContent"); + } + + @Test + void consecutiveHeadingsGetBlankLineBetweenThem() { + assertThat(apply(funcHeadingBlanks(), "== Section A\n=== Subsection")) + .isEqualTo("== Section A\n\n=== Subsection"); + } + + @Test + void headingInsideCodeBlockGetsNoBlankLine() { + // The "== heading" inside ---- must not acquire surrounding blank lines + String input = "----\n== not a real heading\ncontent\n----"; + assertThat(apply(funcHeadingBlanks(), input)).isEqualTo(input); + } + + @Test + void ensureHeadingBlankLinesIsIdempotent() throws Exception { + String input = "Intro\n== Section\nBody text\n=== Sub\nMore"; + String once = apply(funcHeadingBlanks(), input); + String twice = apply(funcHeadingBlanks(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void setextNormalizationThenHeadingBlankLinesThenTitleCase() { + // Exercises the three ordering-dependent transformations in sequence: + // setext → ATX (normalizeSetextHeadings), then blank-line padding + // (ensureHeadingBlankLines), then title-casing (titleCase). + AsciidocFormatterFunc f = funcWith(cfg -> { + cfg.setNormalizeSetextHeadings(true); + cfg.setEnsureHeadingBlankLines(true); + cfg.setTitleCase(true); + }); + String input = "some text\nmy cool section\n---------------\nsome body"; + assertThat(apply(f, input)) + .isEqualTo("some text\n\n== My Cool Section\n\nsome body"); + } + + @Test + void crlfHeadingRecognizedAfterTrailingWhitespaceRemoval() { + // Without removeTrailingWhitespace the \r ends up in the heading text; + // verify that the combination produces correct output. + AsciidocFormatterFunc f = funcWith(cfg -> { + cfg.setRemoveTrailingWhitespace(true); + cfg.setRemoveTrailingHeaderEqualsSign(true); + }); + assertThat(apply(f, "== Title\r\n\r\nBody.\r\n")) + .isEqualTo("== Title\n\nBody.\n"); + } +} diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocLineHandlerTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocLineHandlerTest.java new file mode 100644 index 0000000000..691cebcdfd --- /dev/null +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocLineHandlerTest.java @@ -0,0 +1,323 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +class AsciidocLineHandlerTest { + + private static AsciidocFormatterFunc funcWith(Consumer customizer) { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + customizer.accept(cfg); + return new AsciidocFormatterFunc(cfg); + } + + private static AsciidocFormatterFunc funcTitleCase() { + return funcWith(cfg -> cfg.setTitleCase(true)); + } + + private static AsciidocFormatterFunc funcTrailingWhitespace() { + return funcWith(cfg -> cfg.setRemoveTrailingWhitespace(true)); + } + + private static AsciidocFormatterFunc funcListBullets() { + return funcWith(cfg -> cfg.setNormalizeListBullets(true)); + } + + private static AsciidocFormatterFunc funcOrderedList() { + return funcWith(cfg -> cfg.setNormalizeOrderedListMarkers(true)); + } + + private static String apply(AsciidocFormatterFunc f, String input) { + try { + return f.apply(input); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void titleCasesLevel1SectionHeading() { + assertThat(apply(funcTitleCase(), "= examples of title case")) + .isEqualTo("= Examples of Title Case"); + } + + @Test + void titleCaseHandlesWordsWithPunctuation() { + assertThat(apply(funcTitleCase(), "== word, and another")) + .isEqualTo("== Word, and Another"); + } + + @Test + void titleCasesLevel2SectionHeading() { + assertThat(apply(funcTitleCase(), "== the quick brown fox")) + .isEqualTo("== The Quick Brown Fox"); + } + + @Test + void titleCasesDeepSectionHeading() { + assertThat(apply(funcTitleCase(), "==== art of the deal")) + .isEqualTo("==== Art of the Deal"); + } + + @Test + void titleCasesBlockTitle() { + assertThat(apply(funcTitleCase(), ".examples of title case")) + .isEqualTo(".Examples of Title Case"); + } + + @Test + void firstWordAlwaysCapitalizedEvenIfInLowercaseSet() { + // "of" is in the lowercase set but as the first word it must be capitalized + assertThat(apply(funcTitleCase(), "== of mice and men")) + .isEqualTo("== Of Mice and Men"); + } + + @Test + void lastWordAlwaysCapitalized() { + // "the" at the end must be capitalized + assertThat(apply(funcTitleCase(), "== end of the")) + .isEqualTo("== End of The"); + } + + @Test + void articlesLowercasedInMiddle() { + assertThat(apply(funcTitleCase(), "== the cat and the hat")) + .isEqualTo("== The Cat and the Hat"); + } + + @Test + void prepositionLowercasedInMiddle() { + assertThat(apply(funcTitleCase(), "== art of war")) + .isEqualTo("== Art of War"); + } + + @Test + void coordinatingConjunctionLowercased() { + assertThat(apply(funcTitleCase(), "== black or white")) + .isEqualTo("== Black or White"); + } + + @Test + void wordWithAttributeReferenceSkipped() { + // {attr} contains special chars — must be left as-is + assertThat(apply(funcTitleCase(), "== {doctitle} overview")) + .isEqualTo("== {doctitle} Overview"); + } + + @Test + void wordWithCodeSpanSkipped() { + assertThat(apply(funcTitleCase(), "== use `code` here")) + .isEqualTo("== Use `code` Here"); + } + + @Test + void wordWithMacroSkipped() { + // link:target[] is an AsciiDoc macro — skip the whole token + assertThat(apply(funcTitleCase(), "== see link:url[] for details")) + .isEqualTo("== See link:url[] for Details"); + } + + @Test + void regularParagraphLineUntouched() { + String input = "this is just regular paragraph text."; + assertThat(apply(funcTitleCase(), input)).isEqualTo(input); + } + + @Test + void alreadyTitleCasedHeadingUnchanged() { + String input = "== Examples of Title Case"; + assertThat(apply(funcTitleCase(), input)).isEqualTo(input); + } + + @Test + void titleCaseIsIdempotent() throws Exception { + String input = "== examples of title case\n\n.a block title with of and the\n\nParagraph."; + String once = apply(funcTitleCase(), input); + String twice = apply(funcTitleCase(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void dotDotLineNotTreatedAsBlockTitle() { + // ..something is not a block title (starts with ..) + String input = "..not a block title"; + assertThat(apply(funcTitleCase(), input)).isEqualTo(input); + } + + @Test + void dotSpaceLineNotTreatedAsBlockTitle() { + // ". item" starts with dot-space — that's an ordered list item, not a block title + String input = ". list item text"; + assertThat(apply(funcTitleCase(), input)).isEqualTo(input); + } + + @Test + void trailingSpacesRemovedFromLine() { + assertThat(apply(funcTrailingWhitespace(), "line with trailing spaces ")) + .isEqualTo("line with trailing spaces"); + } + + @Test + void trailingTabRemovedFromLine() { + assertThat(apply(funcTrailingWhitespace(), "line with tab\t")) + .isEqualTo("line with tab"); + } + + @Test + void lineWithoutTrailingWhitespaceUnchanged() { + String input = "clean line"; + assertThat(apply(funcTrailingWhitespace(), input)).isEqualTo(input); + } + + @Test + void blankLineReducedToEmpty() { + assertThat(apply(funcTrailingWhitespace(), " ")).isEqualTo(""); + } + + @Test + void trailingWhitespaceRemovedFromMultipleLines() { + assertThat(apply(funcTrailingWhitespace(), "first \nsecond\t\nthird ")) + .isEqualTo("first\nsecond\nthird"); + } + + @Test + void removeTrailingWhitespaceIsIdempotent() throws Exception { + String input = "line one \nline two\t"; + String once = apply(funcTrailingWhitespace(), input); + String twice = apply(funcTrailingWhitespace(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void dashListItemConvertedToAsterisk() { + assertThat(apply(funcListBullets(), "- first item")) + .isEqualTo("* first item"); + } + + @Test + void multipleDashItemsAllConverted() { + assertThat(apply(funcListBullets(), "- one\n- two\n- three")) + .isEqualTo("* one\n* two\n* three"); + } + + @Test + void asteriskListItemUnchanged() { + String input = "* existing asterisk item"; + assertThat(apply(funcListBullets(), input)).isEqualTo(input); + } + + @Test + void nestedAsteriskItemsUnchanged() { + String input = "* level one\n** level two\n*** level three"; + assertThat(apply(funcListBullets(), input)).isEqualTo(input); + } + + @Test + void dashInsideCodeBlockUntouched() { + String input = "----\n- not a list item\n----"; + assertThat(apply(funcListBullets(), input)).isEqualTo(input); + } + + @Test + void blockDelimiterDashesNotConvertedToAsterisk() { + // "----" is a block delimiter, not a list item + String input = "----\ncode\n----"; + assertThat(apply(funcListBullets(), input)).isEqualTo(input); + } + + @Test + void listBulletsNormalizationIsIdempotent() throws Exception { + String input = "- one\n- two"; + String once = apply(funcListBullets(), input); + String twice = apply(funcListBullets(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void numberedListItemConvertedToAsciiDocStyle() { + assertThat(apply(funcOrderedList(), "1. First item")) + .isEqualTo(". First item"); + } + + @Test + void largeNumberConvertedToAsciiDocStyle() { + assertThat(apply(funcOrderedList(), "42. Some item")) + .isEqualTo(". Some item"); + } + + @Test + void multipleNumberedItemsAllConverted() { + assertThat(apply(funcOrderedList(), "1. First\n2. Second\n3. Third")) + .isEqualTo(". First\n. Second\n. Third"); + } + + @Test + void asciiDocDotStyleUnchanged() { + String input = ". First\n. Second"; + assertThat(apply(funcOrderedList(), input)).isEqualTo(input); + } + + @Test + void numberedListInsideCodeBlockUntouched() { + String input = "----\n1. not a list item\n----"; + assertThat(apply(funcOrderedList(), input)).isEqualTo(input); + } + + @Test + void decimalNumberNotConvertedToListMarker() { + // "3.14" does not match the "digit(s) dot space" pattern + String input = "Version 3.14 is released."; + assertThat(apply(funcOrderedList(), input)).isEqualTo(input); + } + + @Test + void orderedListNormalizationIsIdempotent() throws Exception { + String input = "1. First\n2. Second\n3. Third"; + String once = apply(funcOrderedList(), input); + String twice = apply(funcOrderedList(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void numberedListWithTabAfterNumberConverted() { + // "1.\titem" must be treated the same as "1. item" + assertThat(apply(funcOrderedList(), "1.\tFirst item")) + .isEqualTo(". First item"); + } + + @Test + void crlfLineEndingsNormalizedToLf() { + // removeTrailingWhitespace strips \r, and join("\n") produces LF-only output + assertThat(apply(funcTrailingWhitespace(), "line one\r\nline two\r\n")) + .isEqualTo("line one\nline two\n"); + } +} diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandlerTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandlerTest.java new file mode 100644 index 0000000000..ca736d88ea --- /dev/null +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandlerTest.java @@ -0,0 +1,291 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.asciidoc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +class AsciidocSentenceHandlerTest { + + private static AsciidocFormatterFunc funcWith(Consumer customizer) { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + customizer.accept(cfg); + return new AsciidocFormatterFunc(cfg); + } + + private static AsciidocFormatterFunc func(boolean ospl) { + return funcWith(cfg -> cfg.setOneSentencePerLine(ospl)); + } + + private static AsciidocFormatterFunc funcCollapse() { + return funcWith(cfg -> cfg.setCollapseConsecutiveBlankLines(true)); + } + + private static String apply(AsciidocFormatterFunc f, String input) { + try { + return f.apply(input); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void singleBlankLinePreserved() { + assertThat(apply(funcCollapse(), "A\n\nB")).isEqualTo("A\n\nB"); + } + + @Test + void twoBlankLinesCollapsedToOne() { + assertThat(apply(funcCollapse(), "A\n\n\nB")).isEqualTo("A\n\nB"); + } + + @Test + void threeBlankLinesCollapsedToOne() { + assertThat(apply(funcCollapse(), "A\n\n\n\nB")).isEqualTo("A\n\nB"); + } + + @Test + void noBlankLinesUnchanged() { + assertThat(apply(funcCollapse(), "A\nB\nC")).isEqualTo("A\nB\nC"); + } + + @Test + void multipleGroupsEachCollapsed() { + assertThat(apply(funcCollapse(), "A\n\n\nB\n\n\n\nC")).isEqualTo("A\n\nB\n\nC"); + } + + @Test + void leadingBlankLinesCollapsed() { + assertThat(apply(funcCollapse(), "\n\n\nA")).isEqualTo("\nA"); + } + + @Test + void trailingBlankLinesCollapsed() { + assertThat(apply(funcCollapse(), "A\n\n\n")).isEqualTo("A\n"); + } + + @Test + void collapseIsIdempotent() throws Exception { + String input = "A\n\n\nB\n\n\n\nC"; + String once = apply(funcCollapse(), input); + String twice = apply(funcCollapse(), once); + assertThat(twice).isEqualTo(once); + } + + @Test + void splitsTwoSentencesOnOneLine() { + String input = "First sentence. Second sentence."; + assertThat(apply(func(true), input)).isEqualTo( + "First sentence.\nSecond sentence."); + } + + @Test + void splitsExclamationAndQuestion() { + String input = "Watch out! Are you sure? Proceed anyway."; + assertThat(apply(func(true), input)).isEqualTo( + "Watch out!\nAre you sure?\nProceed anyway."); + } + + @Test + void joinsMultiLineParagraphThenSplits() { + String input = "This is a long sentence that\nspans multiple lines. Second sentence."; + assertThat(apply(func(true), input)).isEqualTo( + "This is a long sentence that spans multiple lines.\nSecond sentence."); + } + + @Test + void idempotent() throws Exception { + String input = "First sentence. Second sentence.\nThird sentence."; + AsciidocFormatterFunc f = func(true); + String once = apply(f, input); + String twice = apply(f, once); + assertThat(twice).isEqualTo(once); + } + + @Test + void drAbbreviationIsNotASentenceBoundary() { + String input = "Consult Dr. Smith before proceeding. Then continue."; + assertThat(apply(func(true), input)).isEqualTo( + "Consult Dr. Smith before proceeding.\nThen continue."); + } + + @Test + void initialIsNotASentenceBoundary() { + String input = "The author is A. Smith. He is famous."; + assertThat(apply(func(true), input)).isEqualTo( + "The author is A. Smith.\nHe is famous."); + } + + @Test + void abbreviationFollowedByCapitalIsNotASentenceBoundary() { + String input = "Item etc. And more. Next sentence."; + assertThat(apply(func(true), input)).isEqualTo( + "Item etc. And more.\nNext sentence."); + } + + @Test + void blockTitleIsSpecialLine() { + String input = ".Block Title\nThis is a sentence. This is another."; + assertThat(apply(func(true), input)).isEqualTo( + ".Block Title\nThis is a sentence.\nThis is another."); + } + + @Test + void doesNotSplitInsideEgAbbreviation() { + String input = "Use a tool (e.g. Spotless) for formatting. It helps."; + assertThat(apply(func(true), input)).isEqualTo( + "Use a tool (e.g. Spotless) for formatting.\nIt helps."); + } + + @Test + void doesNotSplitDecimalNumber() { + String input = "The value is 3.14 approximately. Use it wisely."; + assertThat(apply(func(true), input)).isEqualTo( + "The value is 3.14 approximately.\nUse it wisely."); + } + + @Test + void doesNotSplitEllipsis() { + String input = "Well... that is interesting. Next point."; + assertThat(apply(func(true), input)).isEqualTo( + "Well... that is interesting.\nNext point."); + } + + @Test + void doesNotTouchHeadings() { + String input = "== Section Title\n\nParagraph text."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void doesNotTouchAttributeEntries() { + String input = ":my-attr: some value\n\nParagraph."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void doesNotTouchBlockAttributes() { + String input = "[source,java]\n----\ncode here\n----\n\nText."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void doesNotTouchListItems() { + String input = "* First item. Still item.\n* Second item."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void doesNotReformatInsideListingBlock() { + String input = "----\nFirst sentence. Second sentence.\n----"; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void doesNotReformatInsideExampleBlock() { + String input = "====\nFirst sentence. Second sentence.\n===="; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void pageBreakIsNotJoinedWithAdjacentMacros() { + // toc::[], <<<, and include:: are structural – they must never be accumulated + // into a paragraph and joined into a single line + String input = "toc::[]\n<<<\ninclude::file.adoc[]"; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void pageBreakBetweenParagraphsPassedThrough() { + String input = "First paragraph.\n<<<\nSecond paragraph."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void includeDirectiveNotJoinedWithParagraph() { + String input = "include::chapter.adoc[]\n\nSome text."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void tocMacroPassedThrough() { + assertThat(apply(func(true), "toc::[]")).isEqualTo("toc::[]"); + } + + @Test + void horizontalRulePassedThrough() { + assertThat(apply(func(true), "Sentence one.\n'''\nSentence two.")) + .isEqualTo("Sentence one.\n'''\nSentence two."); + } + + @Test + void dashHorizontalRulePassedThrough() { + assertThat(apply(func(true), "Sentence one.\n---\nSentence two.")) + .isEqualTo("Sentence one.\n---\nSentence two."); + } + + @Test + void asteriskHorizontalRulePassedThrough() { + assertThat(apply(func(true), "Sentence one.\n***\nSentence two.")) + .isEqualTo("Sentence one.\n***\nSentence two."); + } + + @Test + void blankLineSeparatesParagraphs() { + String input = "Paragraph one sentence one. Sentence two.\n\nParagraph two."; + assertThat(apply(func(true), input)).isEqualTo( + "Paragraph one sentence one.\nSentence two.\n\nParagraph two."); + } + + @Test + void doesNotMangleSetextHeading() { + String input = "My Section\n----------\n\nParagraph text."; + assertThat(apply(func(true), input)).isEqualTo(input); + } + + @Test + void singleSentenceReturnedAsIs() { + assertThat(apply(func(true), "Just one sentence.")).isEqualTo("Just one sentence."); + } + + @Test + void lowercaseAfterPeriodIsNotASplit() { + assertThat(apply(func(true), "lowercase follows. not a new sentence. no split here.")) + .isEqualTo("lowercase follows. not a new sentence. no split here."); + } + + @Test + void numberedListWithTabNotMangledByOneSentencePerLine() { + // "1.\titem" must be recognised as a list item (special line) so that + // oneSentencePerLine does not join consecutive items into one long line + String input = "1.\tFirst item\n2.\tSecond item\n3.\tThird item"; + assertThat(apply(func(true), input)).isEqualTo(input); + } +} diff --git a/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/AsciidocExtensionTest.java b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/AsciidocExtensionTest.java new file mode 100644 index 0000000000..a001b9629f --- /dev/null +++ b/plugin-gradle/src/test/java/com/diffplug/gradle/spotless/AsciidocExtensionTest.java @@ -0,0 +1,139 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.gradle.spotless; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +class AsciidocExtensionTest extends GradleIntegrationHarness { + + private static final String[] BUILD_SCRIPT_DEFAULT = { + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "spotless {", + " asciidoc {", + " target '**/*.adoc'", + " asciidoc()", + " }", + "}" + }; + + @Test + void defaultFormattingApplied() throws IOException { + setFile("build.gradle").toLines(BUILD_SCRIPT_DEFAULT); + setFile("docs/sample.adoc").toResource("asciidoc/asciidocBefore.adoc"); + + gradleRunner().withArguments("spotlessApply").build(); + + assertFile("docs/sample.adoc").sameAsResource("asciidoc/asciidocAfter.adoc"); + } + + @Test + void spotlessCheckFailsOnUnformattedThenPassesAfterApply() throws IOException { + setFile("build.gradle").toLines(BUILD_SCRIPT_DEFAULT); + setFile("docs/sample.adoc").toResource("asciidoc/asciidocBefore.adoc"); + + String output = gradleRunner().withArguments("spotlessCheck").buildAndFail().getOutput(); + assertThat(output).contains("docs/sample.adoc"); + + gradleRunner().withArguments("spotlessApply").build(); + gradleRunner().withArguments("spotlessCheck").build(); + } + + @Test + void missingTargetFailsBuild() throws IOException { + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "spotless {", + " asciidoc {", + " asciidoc()", + " }", + "}"); + setFile("docs/sample.adoc").toContent("= Title\n\nSome text."); + + String output = gradleRunner().withArguments("spotlessApply").buildAndFail().getOutput(); + assertThat(output).containsIgnoringCase("target"); + } + + @Test + void titleCaseOptionApplied() throws IOException { + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "spotless {", + " asciidoc {", + " target '**/*.adoc'", + " asciidoc()", + " .normalizeSetextHeadings(false)", + " .collapseConsecutiveBlankLines(false)", + " .oneSentencePerLine(false)", + " .normalizeBlockDelimiters(false)", + " .removeTrailingHeaderEqualsSign(false)", + " .removeTrailingWhitespace(false)", + " .ensureHeadingBlankLines(false)", + " .titleCase(true)", + "}", + "}"); + setFile("docs/sample.adoc").toContent("= my document title\n\n== a section heading"); + + gradleRunner().withArguments("spotlessApply").build(); + + assertFile("docs/sample.adoc").hasContent("= My Document Title\n\n== A Section Heading"); + } + + @Test + void ensureSourceDelimitersOptionApplied() throws IOException { + setFile("build.gradle").toLines( + "plugins {", + " id 'com.diffplug.spotless'", + "}", + "spotless {", + " asciidoc {", + " target '**/*.adoc'", + " asciidoc()", + " .normalizeSetextHeadings(false)", + " .collapseConsecutiveBlankLines(false)", + " .oneSentencePerLine(false)", + " .normalizeBlockDelimiters(false)", + " .removeTrailingHeaderEqualsSign(false)", + " .removeTrailingWhitespace(false)", + " .ensureHeadingBlankLines(false)", + " .ensureSourceDelimiters(true)", + "}", + "}"); + setFile("docs/sample.adoc").toContent("[source,java]\npublic void foo() {}"); + + gradleRunner().withArguments("spotlessApply").build(); + + assertFile("docs/sample.adoc").hasContent("[source,java]\n----\npublic void foo() {}\n----"); + } + + @Test + void alreadyFormattedFileIsUpToDate() throws IOException { + setFile("build.gradle").toLines(BUILD_SCRIPT_DEFAULT); + setFile("docs/sample.adoc").toResource("asciidoc/asciidocAfter.adoc"); + + applyIsUpToDate(false); + applyIsUpToDate(true); + } +} From d668b9543782eee90676860359f219c1b8b31735 Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 15:45:45 +0200 Subject: [PATCH 12/16] Split AsciidocSupport class into the specific handler classes --- .../asciidoc/AsciidocBlockHandler.java | 22 ++- .../asciidoc/AsciidocFormatterFunc.java | 4 +- .../asciidoc/AsciidocHeadingHandler.java | 53 +++++- .../asciidoc/AsciidocLineHandler.java | 28 ++- .../asciidoc/AsciidocSentenceHandler.java | 66 ++++++- .../spotless/asciidoc/AsciidocSupport.java | 169 ------------------ .../spotless/asciidoc/BlockTracker.java | 8 +- 7 files changed, 164 insertions(+), 186 deletions(-) delete mode 100644 lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java index 1b5012ea5f..ad8313fd67 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java @@ -19,6 +19,7 @@ import java.util.Collection; import java.util.List; import java.util.regex.Pattern; +import java.util.stream.IntStream; /** Handles transformations for Asciidoc blocks (delimiters, source blocks). */ final class AsciidocBlockHandler { @@ -28,9 +29,20 @@ final class AsciidocBlockHandler { this.lines = lines; } + private static final String BLOCK_DELIMITER_CHARS = "-=.*_+/"; + // Source / listing block attribute lines: [source], [source,java], [listing], [source%linenums,java], [source#id,java], etc. private static final Pattern SOURCE_BLOCK_ATTR = Pattern.compile("^\\[(source|listing)[,\\]%#].*"); + static boolean isBlockDelimiter(CharSequence line) { + int len = line.length(); + if (len < 4) { + return false; + } + char c = line.charAt(0); + return BLOCK_DELIMITER_CHARS.indexOf(c) >= 0 && IntStream.range(1, len).noneMatch(i -> line.charAt(i) != c); + } + void normalizeBlockDelimiters() { BlockTracker bt = new BlockTracker(); @@ -44,19 +56,19 @@ void normalizeBlockDelimiters() { } else if (isOverLongBlockDelimiter(line)) { String prev = i == 0 ? null : lines.get(i - 1); boolean notSetextUnderline = prev == null || prev.isBlank() - || AsciidocSupport.detectSetextUnderline(prev, line) == null; + || AsciidocHeadingHandler.detectSetextUnderline(prev, line) == null; if (notSetextUnderline) { lines.set(i, String.valueOf(line.charAt(0)).repeat(4)); bt.open(line); } - } else if (AsciidocSupport.isBlockDelimiter(line)) { + } else if (isBlockDelimiter(line)) { bt.open(line); } } } private static boolean isOverLongBlockDelimiter(CharSequence line) { - return line.length() > 4 && AsciidocSupport.isBlockDelimiter(line); + return line.length() > 4 && isBlockDelimiter(line); } void ensureSourceDelimiters() { @@ -73,7 +85,7 @@ void ensureSourceDelimiters() { continue; } - if (AsciidocSupport.isBlockDelimiter(line)) { + if (isBlockDelimiter(line)) { result.add(line); bt.open(line); i++; @@ -85,7 +97,7 @@ void ensureSourceDelimiters() { i++; if (i < lines.size()) { String next = lines.get(i); - if (AsciidocSupport.isBlockDelimiter(next)) { + if (isBlockDelimiter(next)) { result.add(next); bt.open(next); i++; diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index 625a986e18..5421c49551 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -52,7 +52,7 @@ public String apply(@NonNull String input) throws Exception { // normalizeSetextHeadings before ensureHeadingBlankLines // - setext headings are converted to ATX first so they receive blank-line padding. if (config.isRemoveTrailingWhitespace()) { - AsciidocSupport.removeTrailingWhitespace(lines); + lineHandler.removeTrailingWhitespace(); } if (config.isNormalizeSetextHeadings()) { headingHandler.normalizeSetextHeadings(); @@ -79,7 +79,7 @@ public String apply(@NonNull String input) throws Exception { sentenceHandler.applySentencePerLine(); } if (config.isCollapseConsecutiveBlankLines()) { - AsciidocSupport.collapseBlankLines(lines); + lineHandler.collapseBlankLines(); } return String.join("\n", lines); diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java index b4072c514a..2c1a254264 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java @@ -20,6 +20,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import edu.umd.cs.findbugs.annotations.Nullable; + /** Handles transformations for Asciidoc headings. */ final class AsciidocHeadingHandler { private final List lines; @@ -39,6 +41,51 @@ final class AsciidocHeadingHandler { // ATX heading prefixes for setext -> ATX conversion: ATX_PREFIX[n] = "=".repeat(n+1) + " " private static final String[] ATX_PREFIX = {"= ", "== ", "=== ", "==== ", "===== ", "====== "}; + @Nullable static Integer detectSetextUnderline(String titleCandidate, CharSequence underlineLine) { + if (titleCandidate.isEmpty()) { + return null; + } + char first = titleCandidate.charAt(0); + if (first == '=' || first == '[' || first == '.' || first == ':' + || first == '*' || first == '-' || first == '|' || first == '+' + || titleCandidate.startsWith("//")) { + return null; + } + if (underlineLine.isEmpty()) { + return null; + } + char underlineChar = underlineLine.charAt(0); + int level; + switch (underlineChar) { + case '=': + level = 0; + break; + case '-': + level = 1; + break; + case '~': + level = 2; + break; + case '^': + level = 3; + break; + case '+': + level = 4; + break; + default: + return null; + } + for (int j = 1; j < underlineLine.length(); j++) { + if (underlineLine.charAt(j) != underlineChar) { + return null; + } + } + if (underlineLine.length() < titleCandidate.length()) { + return null; + } + return level; + } + void normalizeSetextHeadings() { BlockTracker bt = new BlockTracker(); int readIdx = 0; @@ -51,14 +98,14 @@ void normalizeSetextHeadings() { readIdx++; continue; } - if (AsciidocSupport.isBlockDelimiter(line)) { + if (AsciidocBlockHandler.isBlockDelimiter(line)) { lines.set(writeIdx++, line); bt.open(line); readIdx++; continue; } if (readIdx + 1 < lines.size()) { - Integer level = AsciidocSupport.detectSetextUnderline(line, lines.get(readIdx + 1)); + Integer level = detectSetextUnderline(line, lines.get(readIdx + 1)); if (level != null) { lines.set(writeIdx++, ATX_PREFIX[level] + line); readIdx += 2; @@ -100,7 +147,7 @@ void ensureHeadingBlankLines() { bt.tryClose(line); continue; } - if (AsciidocSupport.isBlockDelimiter(line)) { + if (AsciidocBlockHandler.isBlockDelimiter(line)) { result.add(line); bt.open(line); continue; diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java index 2169afd391..5804cd23fd 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.java @@ -22,7 +22,7 @@ import java.util.regex.Pattern; import java.util.stream.IntStream; -/** Handles line-level transformations for Asciidoc (title case, lists). */ +/** Handles line-level transformations for Asciidoc (title case, lists, whitespace). */ final class AsciidocLineHandler { private static final Pattern MULTIPLE_SPACES = Pattern.compile(" +"); @@ -32,6 +32,30 @@ final class AsciidocLineHandler { this.lines = lines; } + void removeTrailingWhitespace() { + lines.replaceAll(String::stripTrailing); + } + + void collapseBlankLines() { + int writeIdx = 0; + int consecutiveBlank = 0; + for (int readIdx = 0; readIdx < lines.size(); readIdx++) { + String line = lines.get(readIdx); + if (line.isBlank()) { + consecutiveBlank++; + if (consecutiveBlank <= 1) { + lines.set(writeIdx++, line); + } + } else { + consecutiveBlank = 0; + lines.set(writeIdx++, line); + } + } + if (writeIdx < lines.size()) { + lines.subList(writeIdx, lines.size()).clear(); + } + } + // Words lowercased in title case (articles, conjunctions, short prepositions) private static final Set TITLE_CASE_LOWERCASE = Set.of( "a", "an", "the", "and", "but", "or", "nor", "for", "yet", "so", "at", "by", "in", "of", @@ -43,7 +67,7 @@ void applyLineTransformations(AsciidocFormatterConfig config) { String line = lines.get(i); if (bt.isOpen()) { bt.tryClose(line); - } else if (AsciidocSupport.isBlockDelimiter(line)) { + } else if (AsciidocBlockHandler.isBlockDelimiter(line)) { bt.open(line); } else { if (config.isTitleCase()) { diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java index d2b554d2df..e76b8c1ed3 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java @@ -21,9 +21,12 @@ import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.regex.Pattern; /** Handles splitting text into one sentence per line. */ final class AsciidocSentenceHandler { + private static final Pattern MULTI_WHITESPACE = Pattern.compile("\\s+"); + private final List lines; AsciidocSentenceHandler(List lines) { @@ -55,14 +58,14 @@ void applySentencePerLine() { continue; } - if (AsciidocSupport.isBlockDelimiter(line)) { + if (AsciidocBlockHandler.isBlockDelimiter(line)) { flushParagraph(paragraphBuffer, result); result.add(line); bt.open(line); continue; } - if (i + 1 < lines.size() && AsciidocSupport.detectSetextUnderline(line, lines.get(i + 1)) != null) { + if (i + 1 < lines.size() && AsciidocHeadingHandler.detectSetextUnderline(line, lines.get(i + 1)) != null) { flushParagraph(paragraphBuffer, result); result.add(line); result.add(lines.get(i + 1)); @@ -70,7 +73,7 @@ void applySentencePerLine() { continue; } - if (line.isBlank() || AsciidocSupport.isSpecialLine(line)) { + if (line.isBlank() || isSpecialLine(line)) { flushParagraph(paragraphBuffer, result); result.add(line); continue; @@ -88,7 +91,7 @@ private static void flushParagraph(Collection buffer, Collection if (buffer.isEmpty()) { return; } - String joined = AsciidocSupport.MULTI_WHITESPACE.matcher(String.join(" ", buffer)).replaceAll(" ").trim(); + String joined = MULTI_WHITESPACE.matcher(String.join(" ", buffer)).replaceAll(" ").trim(); result.addAll(splitIntoSentences(joined)); buffer.clear(); } @@ -178,4 +181,59 @@ private static boolean isSentenceClosingChar(char c) { || c == '\u2019' || c == '\u201D'; } + + static boolean isSpecialLine(String line) { + if (line.isEmpty()) { + return false; + } + char first = line.charAt(0); + if (first == '=' || first == '[' || first == '|' || first == ' ' || first == '\t') { + return true; + } + if (line.startsWith("//") || line.startsWith("<<<") || "'''".equals(line) || "+".equals(line)) { + return true; + } + if (first == ':' && line.length() > 1 && line.charAt(1) != ':') { + return true; + } + if (first == '.' || first == '*' || first == '-') { + if (line.length() > 1 && line.charAt(1) != first && line.charAt(1) != ' ') { + if (first == '.') { + return true; // Block title (.Title) + } + } + // Treat list items as special lines + if (line.length() > 1 && line.charAt(1) == ' ') { + return true; + } + int i = 1; + while (i < line.length() && line.charAt(i) == first) { + i++; + } + return i == line.length() && i >= 3 || i < line.length() && line.charAt(i) == ' '; // Horizontal rule (--- or ***) + } + if (Character.isDigit(first)) { + int i = 1; + while (i < line.length() && Character.isDigit(line.charAt(i))) { + i++; + } + return i + 1 < line.length() && line.charAt(i) == '.' + && (line.charAt(i + 1) == ' ' || line.charAt(i + 1) == '\t'); + } + return isBlockMacroOrTerm(line); + } + + private static boolean isBlockMacroOrTerm(CharSequence line) { + int len = line.length(); + int i = 0; + while (i < len) { + char c = line.charAt(i); + if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' || c >= '0' && c <= '9') { + i++; + } else { + break; + } + } + return i > 0 && i + 1 < len && line.charAt(i) == ':' && line.charAt(i + 1) == ':'; + } } diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java deleted file mode 100644 index d05fe2ff1d..0000000000 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSupport.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2026 DiffPlug - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.diffplug.spotless.asciidoc; - -import java.util.List; -import java.util.regex.Pattern; -import java.util.stream.IntStream; - -import edu.umd.cs.findbugs.annotations.Nullable; - -/** Shared utilities and constants for Asciidoc formatting. */ -final class AsciidocSupport { - private AsciidocSupport() {} - - private static final String BLOCK_DELIMITER_CHARS = "-=.*_+/"; - - static final Pattern MULTI_WHITESPACE = Pattern.compile("\\s+"); - - static void removeTrailingWhitespace(List lines) { - lines.replaceAll(String::stripTrailing); - } - - static void collapseBlankLines(List lines) { - int writeIdx = 0; - int consecutiveBlank = 0; - for (int readIdx = 0; readIdx < lines.size(); readIdx++) { - String line = lines.get(readIdx); - if (line.isBlank()) { - consecutiveBlank++; - if (consecutiveBlank <= 1) { - lines.set(writeIdx++, line); - } - } else { - consecutiveBlank = 0; - lines.set(writeIdx++, line); - } - } - if (writeIdx < lines.size()) { - lines.subList(writeIdx, lines.size()).clear(); - } - } - - static boolean isBlockDelimiter(CharSequence line) { - int len = line.length(); - if (len < 4) { - return false; - } - char c = line.charAt(0); - return BLOCK_DELIMITER_CHARS.indexOf(c) >= 0 && IntStream.range(1, len).noneMatch(i -> line.charAt(i) != c); - } - - static boolean isAllSameChar(CharSequence line, char c) { - return IntStream.range(0, line.length()).noneMatch(i -> line.charAt(i) != c); - } - - @Nullable static Integer detectSetextUnderline(String titleCandidate, CharSequence underlineLine) { - if (titleCandidate.isEmpty()) { - return null; - } - char first = titleCandidate.charAt(0); - if (first == '=' || first == '[' || first == '.' || first == ':' - || first == '*' || first == '-' || first == '|' || first == '+' - || titleCandidate.startsWith("//")) { - return null; - } - if (underlineLine.isEmpty()) { - return null; - } - char underlineChar = underlineLine.charAt(0); - int level; - switch (underlineChar) { - case '=': - level = 0; - break; - case '-': - level = 1; - break; - case '~': - level = 2; - break; - case '^': - level = 3; - break; - case '+': - level = 4; - break; - default: - return null; - } - for (int j = 1; j < underlineLine.length(); j++) { - if (underlineLine.charAt(j) != underlineChar) { - return null; - } - } - if (underlineLine.length() < titleCandidate.length()) { - return null; - } - return level; - } - - static boolean isSpecialLine(String line) { - if (line.isEmpty()) { - return false; - } - char first = line.charAt(0); - if (first == '=' || first == '[' || first == '|' || first == ' ' || first == '\t') { - return true; - } - if (line.startsWith("//") || line.startsWith("<<<") || "'''".equals(line) || "+".equals(line)) { - return true; - } - if (first == ':' && line.length() > 1 && line.charAt(1) != ':') { - return true; - } - if (first == '.' || first == '*' || first == '-') { - if (line.length() > 1 && line.charAt(1) != first && line.charAt(1) != ' ') { - if (first == '.') { - return true; // Block title (.Title) - } - } - // Treat list items as special lines - if (line.length() > 1 && line.charAt(1) == ' ') { - return true; - } - int i = 1; - while (i < line.length() && line.charAt(i) == first) { - i++; - } - return i == line.length() && i >= 3 || i < line.length() && line.charAt(i) == ' '; // Horizontal rule (--- or ***) - } - if (Character.isDigit(first)) { - int i = 1; - while (i < line.length() && Character.isDigit(line.charAt(i))) { - i++; - } - return i + 1 < line.length() && line.charAt(i) == '.' - && (line.charAt(i + 1) == ' ' || line.charAt(i + 1) == '\t'); - } - return isBlockMacroOrTerm(line); - } - - private static boolean isBlockMacroOrTerm(CharSequence line) { - int len = line.length(); - int i = 0; - while (i < len) { - char c = line.charAt(i); - if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' || c >= '0' && c <= '9') { - i++; - } else { - break; - } - } - return i > 0 && i + 1 < len && line.charAt(i) == ':' && line.charAt(i + 1) == ':'; - } - -} diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java index c77fac8355..2b9ac69133 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java @@ -15,6 +15,8 @@ */ package com.diffplug.spotless.asciidoc; +import java.util.stream.IntStream; + import edu.umd.cs.findbugs.annotations.Nullable; class BlockTracker { @@ -29,11 +31,15 @@ void open(CharSequence line) { } @Nullable String tryClose(CharSequence line) { - if (delimChar != '\0' && line.length() >= 4 && AsciidocSupport.isAllSameChar(line, delimChar)) { + if (delimChar != '\0' && line.length() >= 4 && isAllSameChar(line, delimChar)) { String closed = String.valueOf(delimChar); delimChar = '\0'; return closed; } return null; } + + static boolean isAllSameChar(CharSequence line, char c) { + return IntStream.range(0, line.length()).noneMatch(i -> line.charAt(i) != c); + } } From 5defe8d62a9d4f13f95fd0da3c44c18192c883cb Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 16:43:17 +0200 Subject: [PATCH 13/16] Fix pipeline ordering bug that corrupted setext titles ending with '=', stop requiring uppercase after '!'/'?' sentence splits, and replace IntStream allocations with plain loops in hot paths. --- .../asciidoc/AsciidocBlockHandler.java | 33 +++++++------ .../asciidoc/AsciidocFormatterFunc.java | 29 ++++++------ .../asciidoc/AsciidocHeadingHandler.java | 6 +-- .../asciidoc/AsciidocSentenceHandler.java | 6 ++- .../spotless/asciidoc/BlockTracker.java | 11 +++-- .../asciidoc/AsciidocFormatterFuncTest.java | 46 +++++++++++++++++++ .../asciidoc/AsciidocHeadingHandlerTest.java | 24 ++++++++++ .../asciidoc/AsciidocSentenceHandlerTest.java | 16 +++++++ 8 files changed, 135 insertions(+), 36 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java index ad8313fd67..3210c77a4b 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java @@ -19,7 +19,6 @@ import java.util.Collection; import java.util.List; import java.util.regex.Pattern; -import java.util.stream.IntStream; /** Handles transformations for Asciidoc blocks (delimiters, source blocks). */ final class AsciidocBlockHandler { @@ -40,7 +39,15 @@ static boolean isBlockDelimiter(CharSequence line) { return false; } char c = line.charAt(0); - return BLOCK_DELIMITER_CHARS.indexOf(c) >= 0 && IntStream.range(1, len).noneMatch(i -> line.charAt(i) != c); + if (BLOCK_DELIMITER_CHARS.indexOf(c) < 0) { + return false; + } + for (int i = 1; i < len; i++) { + if (line.charAt(i) != c) { + return false; + } + } + return true; } void normalizeBlockDelimiters() { @@ -53,24 +60,22 @@ void normalizeBlockDelimiters() { if (closed != null) { lines.set(i, closed.repeat(4)); } - } else if (isOverLongBlockDelimiter(line)) { - String prev = i == 0 ? null : lines.get(i - 1); - boolean notSetextUnderline = prev == null || prev.isBlank() - || AsciidocHeadingHandler.detectSetextUnderline(prev, line) == null; - if (notSetextUnderline) { - lines.set(i, String.valueOf(line.charAt(0)).repeat(4)); + } else if (isBlockDelimiter(line)) { + if (line.length() > 4) { + String prev = i == 0 ? null : lines.get(i - 1); + boolean notSetextUnderline = prev == null || prev.isBlank() + || AsciidocHeadingHandler.detectSetextUnderline(prev, line) == null; + if (notSetextUnderline) { + lines.set(i, String.valueOf(line.charAt(0)).repeat(4)); + bt.open(lines.get(i)); + } + } else { bt.open(line); } - } else if (isBlockDelimiter(line)) { - bt.open(line); } } } - private static boolean isOverLongBlockDelimiter(CharSequence line) { - return line.length() > 4 && isBlockDelimiter(line); - } - void ensureSourceDelimiters() { Collection result = new ArrayList<>(lines.size() + 8); BlockTracker bt = new BlockTracker(); diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java index 5421c49551..80c8ad9383 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -30,6 +30,8 @@ */ public class AsciidocFormatterFunc implements FormatterFunc { + private static final Pattern LINE_SPLITTER = Pattern.compile("\\R"); + private final AsciidocFormatterConfig config; public AsciidocFormatterFunc(AsciidocFormatterConfig config) { @@ -38,34 +40,34 @@ public AsciidocFormatterFunc(AsciidocFormatterConfig config) { @NonNull @Override public String apply(@NonNull String input) throws Exception { - // Use \R to match any line break (LF, CRLF, CR) and avoid multiple replacements - List lines = new ArrayList<>(Arrays.asList(Pattern.compile("\\R").split(input, -1))); - - AsciidocBlockHandler blockHandler = new AsciidocBlockHandler(lines); - AsciidocHeadingHandler headingHandler = new AsciidocHeadingHandler(lines); - AsciidocLineHandler lineHandler = new AsciidocLineHandler(lines); - AsciidocSentenceHandler sentenceHandler = new AsciidocSentenceHandler(lines); - + List lines = new ArrayList<>(Arrays.asList(LINE_SPLITTER.split(input, -1))); // Ordering constraints: - // removeTrailingWhitespace before collapseConsecutiveBlankLines + // removeTrailingWhitespace before collapseConsecutiveBlankLines // - whitespace-only lines must be emptied before they can be collapsed. - // normalizeSetextHeadings before ensureHeadingBlankLines + // removeTrailingHeaderEqualsSign before normalizeSetextHeadings + // - symmetric ATX headings must be cleaned before setext conversion so that + // a setext title ending with '=' is not later mistaken for symmetric decoration. + // normalizeSetextHeadings before ensureHeadingBlankLines // - setext headings are converted to ATX first so they receive blank-line padding. + + AsciidocLineHandler lineHandler = new AsciidocLineHandler(lines); if (config.isRemoveTrailingWhitespace()) { lineHandler.removeTrailingWhitespace(); } + AsciidocHeadingHandler headingHandler = new AsciidocHeadingHandler(lines); + if (config.isRemoveTrailingHeaderEqualsSign()) { + headingHandler.removeTrailingHeaderEqualsSign(); + } if (config.isNormalizeSetextHeadings()) { headingHandler.normalizeSetextHeadings(); } + AsciidocBlockHandler blockHandler = new AsciidocBlockHandler(lines); if (config.isEnsureSourceDelimiters()) { blockHandler.ensureSourceDelimiters(); } if (config.isNormalizeBlockDelimiters()) { blockHandler.normalizeBlockDelimiters(); } - if (config.isRemoveTrailingHeaderEqualsSign()) { - headingHandler.removeTrailingHeaderEqualsSign(); - } // Combine simple line-by-line transforms into a single in-place pass if (config.isTitleCase() || config.isNormalizeListBullets() || config.isNormalizeOrderedListMarkers()) { @@ -75,6 +77,7 @@ public String apply(@NonNull String input) throws Exception { if (config.isEnsureHeadingBlankLines()) { headingHandler.ensureHeadingBlankLines(); } + AsciidocSentenceHandler sentenceHandler = new AsciidocSentenceHandler(lines); if (config.isOneSentencePerLine()) { sentenceHandler.applySentencePerLine(); } diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java index 2c1a254264..138f8390ad 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java @@ -75,14 +75,14 @@ final class AsciidocHeadingHandler { default: return null; } + if (underlineLine.length() < titleCandidate.length()) { + return null; + } for (int j = 1; j < underlineLine.length(); j++) { if (underlineLine.charAt(j) != underlineChar) { return null; } } - if (underlineLine.length() < titleCandidate.length()) { - return null; - } return level; } diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java index e76b8c1ed3..536a8d12e7 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java @@ -42,7 +42,7 @@ final class AsciidocSentenceHandler { "corp", "inc", "ltd", "llc", "jan", "feb", "mar", "apr", "jun", "jul", "aug", "sep", "sept", "oct", "nov", "dec", - "bspw", "bzw", "bzgl", "ca", "evtl", "exkl", "inkl", "sog"); + "bspw", "bzw", "bzgl", "ca", "evtl", "exkl", "inkl", "sog", "art"); void applySentencePerLine() { Collection result = new ArrayList<>(lines.size()); @@ -138,7 +138,9 @@ private static List splitIntoSentences(String text) { while (k < text.length() && Character.isWhitespace(text.charAt(k))) { k++; } - if (k >= text.length() || Character.isUpperCase(text.charAt(k)) || Character.isDigit(text.charAt(k))) { + // For '.' require uppercase/digit to avoid splitting on abbreviations. + // For '!' and '?' always split — they are unambiguous sentence terminators. + if (c != '.' || k >= text.length() || Character.isUpperCase(text.charAt(k)) || Character.isDigit(text.charAt(k))) { String sentence = text.substring(start, j).trim(); if (!sentence.isEmpty()) { sentences.add(sentence); diff --git a/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java index 2b9ac69133..d14f96ef5e 100644 --- a/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java @@ -15,8 +15,6 @@ */ package com.diffplug.spotless.asciidoc; -import java.util.stream.IntStream; - import edu.umd.cs.findbugs.annotations.Nullable; class BlockTracker { @@ -39,7 +37,12 @@ void open(CharSequence line) { return null; } - static boolean isAllSameChar(CharSequence line, char c) { - return IntStream.range(0, line.length()).noneMatch(i -> line.charAt(i) != c); + private static boolean isAllSameChar(CharSequence line, char c) { + for (int i = 0; i < line.length(); i++) { + if (line.charAt(i) != c) { + return false; + } + } + return true; } } diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java index 3a3d8fca70..21db19ec50 100644 --- a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java @@ -17,10 +17,56 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.function.Consumer; + import org.junit.jupiter.api.Test; class AsciidocFormatterFuncTest { + private static AsciidocFormatterFunc funcWith(Consumer customizer) { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + cfg.setNormalizeSetextHeadings(false); + cfg.setCollapseConsecutiveBlankLines(false); + cfg.setOneSentencePerLine(false); + cfg.setNormalizeBlockDelimiters(false); + cfg.setRemoveTrailingHeaderEqualsSign(false); + cfg.setTitleCase(false); + cfg.setRemoveTrailingWhitespace(false); + cfg.setNormalizeListBullets(false); + cfg.setNormalizeOrderedListMarkers(false); + cfg.setEnsureHeadingBlankLines(false); + cfg.setEnsureSourceDelimiters(false); + customizer.accept(cfg); + return new AsciidocFormatterFunc(cfg); + } + + @Test + void removeTrailingEqualsRunsBeforeSetextNormalization() throws Exception { + // Ordering constraint: removeTrailingHeaderEqualsSign must run before + // normalizeSetextHeadings. If the order were reversed, "Config =\n========" + // would first become "= Config =" and then have the trailing '=' stripped as + // symmetric decoration, yielding "= Config" instead of "= Config =". + AsciidocFormatterFunc f = funcWith(cfg -> { + cfg.setRemoveTrailingHeaderEqualsSign(true); + cfg.setNormalizeSetextHeadings(true); + }); + assertThat(f.apply("Config =\n========")).isEqualTo("= Config ="); + } + + @Test + void setextNormalizationRunsBeforeHeadingBlankLines() throws Exception { + // Ordering constraint: normalizeSetextHeadings must run before + // ensureHeadingBlankLines. If the order were reversed, ensureHeadingBlankLines + // would see a plain paragraph line (the setext title candidate) and add no + // padding; the converted ATX heading would then lack its surrounding blank lines. + AsciidocFormatterFunc f = funcWith(cfg -> { + cfg.setNormalizeSetextHeadings(true); + cfg.setEnsureHeadingBlankLines(true); + }); + assertThat(f.apply("Before\nSection Title\n=============\nAfter")) + .isEqualTo("Before\n\n= Section Title\n\nAfter"); + } + @Test void appliesMultipleFormattingRules() throws Exception { AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandlerTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandlerTest.java index eeaaa1f206..403de48d0e 100644 --- a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandlerTest.java +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandlerTest.java @@ -142,6 +142,30 @@ void doesNotConvertClosingBracketBeforeBlockDelimiter() { assertThat(apply(funcSetext(), input)).isEqualTo(input); } + @Test + void setextTitleEndingWithEqualsPreservedAfterTrailingEqualsRemoval() { + // Regression: removeTrailingHeaderEqualsSign previously ran after + // normalizeSetextHeadings, converting "Config =\n========" into "= Config =" + // and then stripping the trailing '=' as if it were symmetric decoration. + // The fix moves removeTrailingHeaderEqualsSign before normalizeSetextHeadings. + AsciidocFormatterFunc f = funcWith(cfg -> { + cfg.setNormalizeSetextHeadings(true); + cfg.setRemoveTrailingHeaderEqualsSign(true); + }); + assertThat(apply(f, "Config =\n========")).isEqualTo("= Config ="); + } + + @Test + void symmetricAtxHeadingStillStrippedWhenBothEnabled() { + // When both features are on, a directly-written symmetric ATX heading + // must still have its trailing decoration removed. + AsciidocFormatterFunc f = funcWith(cfg -> { + cfg.setNormalizeSetextHeadings(true); + cfg.setRemoveTrailingHeaderEqualsSign(true); + }); + assertThat(apply(f, "== Version ==")).isEqualTo("== Version"); + } + @Test void setextNormalizationIsIdempotent() throws Exception { String input = "My Title\n========\n\nA Section\n---------"; diff --git a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandlerTest.java b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandlerTest.java index ca736d88ea..2ce4389fb0 100644 --- a/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandlerTest.java +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandlerTest.java @@ -113,6 +113,22 @@ void splitsExclamationAndQuestion() { "Watch out!\nAre you sure?\nProceed anyway."); } + @Test + void exclamationSplitsBeforeLowercaseWord() { + // Regression: '!' previously required the following word to start with + // uppercase, so "Stop! don't move." was never split. + assertThat(apply(func(true), "Stop! don't move. Please continue.")) + .isEqualTo("Stop!\ndon't move.\nPlease continue."); + } + + @Test + void questionMarkSplitsBeforeLowercaseWord() { + // Regression: '?' previously required the following word to start with + // uppercase, so "Really? maybe not." was never split. + assertThat(apply(func(true), "Really? maybe not. Let's check.")) + .isEqualTo("Really?\nmaybe not.\nLet's check."); + } + @Test void joinsMultiLineParagraphThenSplits() { String input = "This is a long sentence that\nspans multiple lines. Second sentence."; From fe0199fffc818361b3ec38079c0f6ebaa5b433ba Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 23:51:53 +0200 Subject: [PATCH 14/16] Add AsciidocFormattingTest --- .../maven/MavenIntegrationHarness.java | 4 ++ .../asciidoc/AsciidocFormattingTest.java | 47 +++++++++++++++++++ .../asciidoc/asciidocMavenDefaultAfter.adoc | 22 +++++++++ .../asciidoc/asciidocMavenDefaultBefore.adoc | 22 +++++++++ 4 files changed, 95 insertions(+) create mode 100644 plugin-maven/src/test/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormattingTest.java create mode 100644 testlib/src/main/resources/asciidoc/asciidocMavenDefaultAfter.adoc create mode 100644 testlib/src/main/resources/asciidoc/asciidocMavenDefaultBefore.adoc diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenIntegrationHarness.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenIntegrationHarness.java index fd6c4b2047..f74b3c023e 100644 --- a/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenIntegrationHarness.java +++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/MavenIntegrationHarness.java @@ -182,6 +182,10 @@ protected void writePomWithProtobufSteps(String... steps) throws IOException { writePom(groupWithSteps("protobuf", steps)); } + protected void writePomWithAsciidocSteps(String... steps) throws IOException { + writePom(groupWithSteps("asciidoc", including("**/*.adoc"), steps)); + } + protected void writePomWithMarkdownSteps(String... steps) throws IOException { writePom(groupWithSteps("markdown", including("**/*.md"), steps)); } diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormattingTest.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormattingTest.java new file mode 100644 index 0000000000..dea3f4e5fd --- /dev/null +++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormattingTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.maven.asciidoc; + +import org.junit.jupiter.api.Test; + +import com.diffplug.spotless.maven.MavenIntegrationHarness; + +class AsciidocFormattingTest extends MavenIntegrationHarness { + + private static final String TEST_FILE_PATH = "src/docs/index.adoc"; + + @Test + void defaultFormattingApply() throws Exception { + writePomWithAsciidocSteps(""); + setFile(TEST_FILE_PATH).toResource("asciidoc/asciidocMavenDefaultBefore.adoc"); + mavenRunner().withArguments("spotless:apply").runNoError(); + assertFile(TEST_FILE_PATH).sameAsResource("asciidoc/asciidocMavenDefaultAfter.adoc"); + } + + @Test + void allNonDefaultOptionsEnabledApply() throws Exception { + writePomWithAsciidocSteps( + "", + " true", + " true", + " true", + " true", + ""); + setFile(TEST_FILE_PATH).toResource("asciidoc/asciidocBefore.adoc"); + mavenRunner().withArguments("spotless:apply").runNoError(); + assertFile(TEST_FILE_PATH).sameAsResource("asciidoc/asciidocAfter.adoc"); + } +} diff --git a/testlib/src/main/resources/asciidoc/asciidocMavenDefaultAfter.adoc b/testlib/src/main/resources/asciidoc/asciidocMavenDefaultAfter.adoc new file mode 100644 index 0000000000..cac13652e6 --- /dev/null +++ b/testlib/src/main/resources/asciidoc/asciidocMavenDefaultAfter.adoc @@ -0,0 +1,22 @@ += First Section + +Trailing whitespace here. + +Some text. + +Another paragraph. + += Second Section + +Without leading blank line. + +Setext heading stays as-is: + +A setext title +~~~~~~~~~~~~~~ + +Long delimiter stays as-is: + +-------- +code +-------- diff --git a/testlib/src/main/resources/asciidoc/asciidocMavenDefaultBefore.adoc b/testlib/src/main/resources/asciidoc/asciidocMavenDefaultBefore.adoc new file mode 100644 index 0000000000..0c1e13c24e --- /dev/null +++ b/testlib/src/main/resources/asciidoc/asciidocMavenDefaultBefore.adoc @@ -0,0 +1,22 @@ += First Section + +Trailing whitespace here. + +Some text. + + +Another paragraph. + += Second Section +Without leading blank line. + +Setext heading stays as-is: + +A setext title +~~~~~~~~~~~~~~ + +Long delimiter stays as-is: + +-------- +code +-------- From 6249d4d0fa1125dd7ecc79dcf67da4adb813c79d Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Tue, 2 Jun 2026 23:55:50 +0200 Subject: [PATCH 15/16] Unify Asciidoc formatting tests --- .../asciidoc/AsciidocFormattingTest.java | 10 +-------- .../asciidoc/asciidocMavenDefaultAfter.adoc | 22 ------------------- .../asciidoc/asciidocMavenDefaultBefore.adoc | 22 ------------------- 3 files changed, 1 insertion(+), 53 deletions(-) delete mode 100644 testlib/src/main/resources/asciidoc/asciidocMavenDefaultAfter.adoc delete mode 100644 testlib/src/main/resources/asciidoc/asciidocMavenDefaultBefore.adoc diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormattingTest.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormattingTest.java index dea3f4e5fd..d27314e0ea 100644 --- a/plugin-maven/src/test/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormattingTest.java +++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormattingTest.java @@ -24,15 +24,7 @@ class AsciidocFormattingTest extends MavenIntegrationHarness { private static final String TEST_FILE_PATH = "src/docs/index.adoc"; @Test - void defaultFormattingApply() throws Exception { - writePomWithAsciidocSteps(""); - setFile(TEST_FILE_PATH).toResource("asciidoc/asciidocMavenDefaultBefore.adoc"); - mavenRunner().withArguments("spotless:apply").runNoError(); - assertFile(TEST_FILE_PATH).sameAsResource("asciidoc/asciidocMavenDefaultAfter.adoc"); - } - - @Test - void allNonDefaultOptionsEnabledApply() throws Exception { + void formattingApply() throws Exception { writePomWithAsciidocSteps( "", " true", diff --git a/testlib/src/main/resources/asciidoc/asciidocMavenDefaultAfter.adoc b/testlib/src/main/resources/asciidoc/asciidocMavenDefaultAfter.adoc deleted file mode 100644 index cac13652e6..0000000000 --- a/testlib/src/main/resources/asciidoc/asciidocMavenDefaultAfter.adoc +++ /dev/null @@ -1,22 +0,0 @@ -= First Section - -Trailing whitespace here. - -Some text. - -Another paragraph. - -= Second Section - -Without leading blank line. - -Setext heading stays as-is: - -A setext title -~~~~~~~~~~~~~~ - -Long delimiter stays as-is: - --------- -code --------- diff --git a/testlib/src/main/resources/asciidoc/asciidocMavenDefaultBefore.adoc b/testlib/src/main/resources/asciidoc/asciidocMavenDefaultBefore.adoc deleted file mode 100644 index 0c1e13c24e..0000000000 --- a/testlib/src/main/resources/asciidoc/asciidocMavenDefaultBefore.adoc +++ /dev/null @@ -1,22 +0,0 @@ -= First Section - -Trailing whitespace here. - -Some text. - - -Another paragraph. - -= Second Section -Without leading blank line. - -Setext heading stays as-is: - -A setext title -~~~~~~~~~~~~~~ - -Long delimiter stays as-is: - --------- -code --------- From 772e278483006d1e25f8aad6e1eff56c48c44d57 Mon Sep 17 00:00:00 2001 From: Daniel Heid Date: Wed, 3 Jun 2026 00:10:12 +0200 Subject: [PATCH 16/16] Use ResourceHarness in AsciidocFormatterStepTest --- .../spotless/asciidoc/AsciidocFormatterStepTest.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/testlib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStepTest.java b/testlib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStepTest.java index 2397fcd2c0..ef11318461 100644 --- a/testlib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStepTest.java @@ -18,15 +18,17 @@ import org.junit.jupiter.api.Test; import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.ResourceHarness; import com.diffplug.spotless.SerializableEqualityTester; import com.diffplug.spotless.StepHarness; -class AsciidocFormatterStepTest { +class AsciidocFormatterStepTest extends ResourceHarness { @Test void behavior() { - StepHarness.forStep(AsciidocFormatterStep.create(new AsciidocFormatterConfig())) - .testResource("asciidoc/asciidocBefore.adoc", "asciidoc/asciidocAfter.adoc"); + try (StepHarness step = StepHarness.forStep(AsciidocFormatterStep.create(new AsciidocFormatterConfig()))) { + step.testResource("asciidoc/asciidocBefore.adoc", "asciidoc/asciidocAfter.adoc"); + } } @Test