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/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/AsciidocBlockHandler.java b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java new file mode 100644 index 0000000000..3210c77a4b --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocBlockHandler.java @@ -0,0 +1,127 @@ +/* + * 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 final List lines; + + AsciidocBlockHandler(List lines) { + 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); + 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() { + 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 (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); + } + } + } + } + + void ensureSourceDelimiters() { + 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 (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); + } +} 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..80c8ad9383 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFunc.java @@ -0,0 +1,90 @@ +/* + * 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.List; +import java.util.regex.Pattern; + +import com.diffplug.spotless.FormatterFunc; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * A formatter function for Asciidoc that applies various formatting rules + * based on the provided configuration. + */ +public class AsciidocFormatterFunc implements FormatterFunc { + + private static final Pattern LINE_SPLITTER = Pattern.compile("\\R"); + + private final AsciidocFormatterConfig config; + + public AsciidocFormatterFunc(AsciidocFormatterConfig config) { + this.config = config; + } + + @NonNull @Override + public String apply(@NonNull String input) throws Exception { + List lines = new ArrayList<>(Arrays.asList(LINE_SPLITTER.split(input, -1))); + // Ordering constraints: + // removeTrailingWhitespace before collapseConsecutiveBlankLines + // - whitespace-only lines must be emptied before they can be collapsed. + // 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(); + } + + // Combine simple line-by-line transforms into a single in-place pass + if (config.isTitleCase() || config.isNormalizeListBullets() || config.isNormalizeOrderedListMarkers()) { + lineHandler.applyLineTransformations(config); + } + + if (config.isEnsureHeadingBlankLines()) { + headingHandler.ensureHeadingBlankLines(); + } + AsciidocSentenceHandler sentenceHandler = new AsciidocSentenceHandler(lines); + if (config.isOneSentencePerLine()) { + sentenceHandler.applySentencePerLine(); + } + if (config.isCollapseConsecutiveBlankLines()) { + lineHandler.collapseBlankLines(); + } + + return String.join("\n", lines); + } +} 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/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..138f8390ad --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandler.java @@ -0,0 +1,171 @@ +/* + * 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; + +import edu.umd.cs.findbugs.annotations.Nullable; + +/** Handles transformations for Asciidoc headings. */ +final class 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) + 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 = {"= ", "== ", "=== ", "==== ", "===== ", "====== "}; + + @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; + } + if (underlineLine.length() < titleCandidate.length()) { + return null; + } + for (int j = 1; j < underlineLine.length(); j++) { + if (underlineLine.charAt(j) != underlineChar) { + return null; + } + } + return level; + } + + void normalizeSetextHeadings() { + 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 (AsciidocBlockHandler.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(); + } + } + + void removeTrailingHeaderEqualsSign() { + 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)); + } + } + } + + void ensureHeadingBlankLines() { + 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 (AsciidocBlockHandler.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..5804cd23fd --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocLineHandler.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.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, whitespace). */ +final class AsciidocLineHandler { + private static final Pattern MULTIPLE_SPACES = Pattern.compile(" +"); + + private final List lines; + + AsciidocLineHandler(List lines) { + 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", + "on", "to", "up", "as", "off", "out", "per", "via", "from", "with"); + + void applyLineTransformations(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 (AsciidocBlockHandler.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..536a8d12e7 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandler.java @@ -0,0 +1,241 @@ +/* + * 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; +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) { + this.lines = lines; + } + + // 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", + "bspw", "bzw", "bzgl", "ca", "evtl", "exkl", "inkl", "sog", "art"); + + void applySentencePerLine() { + 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 (AsciidocBlockHandler.isBlockDelimiter(line)) { + flushParagraph(paragraphBuffer, result); + result.add(line); + bt.open(line); + continue; + } + + 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)); + 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(Collection buffer, Collection result) { + if (buffer.isEmpty()) { + return; + } + String joined = 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++; + } + // 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); + } + 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'; + } + + 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 new file mode 100644 index 0000000000..d14f96ef5e --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/asciidoc/BlockTracker.java @@ -0,0 +1,48 @@ +/* + * 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 && isAllSameChar(line, delimChar)) { + String closed = String.valueOf(delimChar); + delimChar = '\0'; + return closed; + } + return null; + } + + 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/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 new file mode 100644 index 0000000000..21db19ec50 --- /dev/null +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterFuncTest.java @@ -0,0 +1,99 @@ +/* + * 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 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(); + cfg.setNormalizeSetextHeadings(true); + cfg.setNormalizeBlockDelimiters(true); + cfg.setTitleCase(true); + cfg.setOneSentencePerLine(true); + cfg.setNormalizeListBullets(true); + cfg.setEnsureSourceDelimiters(true); + + AsciidocFormatterFunc func = new AsciidocFormatterFunc(cfg); + + 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----"; + + assertThat(func.apply(input)).isEqualTo(expected); + } + + @Test + void appliesNoFormattingWhenConfigDisabled() throws Exception { + AsciidocFormatterConfig cfg = new AsciidocFormatterConfig(); + // All defaults are false + + AsciidocFormatterFunc func = new AsciidocFormatterFunc(cfg); + + String input = "some text"; + + 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..403de48d0e --- /dev/null +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocHeadingHandlerTest.java @@ -0,0 +1,310 @@ +/* + * 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 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---------"; + 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..2ce4389fb0 --- /dev/null +++ b/lib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocSentenceHandlerTest.java @@ -0,0 +1,307 @@ +/* + * 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 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."; + 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/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-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-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); + } +} 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 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/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..d27314e0ea --- /dev/null +++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/asciidoc/AsciidocFormattingTest.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 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 formattingApply() 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/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..ef11318461 --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/asciidoc/AsciidocFormatterStepTest.java @@ -0,0 +1,97 @@ +/* + * 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.ResourceHarness; +import com.diffplug.spotless.SerializableEqualityTester; +import com.diffplug.spotless.StepHarness; + +class AsciidocFormatterStepTest extends ResourceHarness { + + @Test + void behavior() { + try (StepHarness step = StepHarness.forStep(AsciidocFormatterStep.create(new AsciidocFormatterConfig()))) { + step.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(); + } +}