This commit is contained in:
@@ -51,7 +51,7 @@ WeComNotifier ────────► ApiChangeNotifier(新
|
||||
**方法指纹建议**(用于跨 commit 匹配同一接口):
|
||||
|
||||
```
|
||||
controller源文件 + 方法名 + 参数类型签名
|
||||
controller源文件 + 方法名 + 参数槽位(如 0:query,1:body;不含类型与绑定名)
|
||||
```
|
||||
|
||||
仅 URI 匹配在「改路径」场景会失效,需指纹辅助。
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
1. 解析旧/新 commit 下同一 Controller 源码 AST
|
||||
2. 提取每个方法的 `httpMethod` + `uri`(已有 `EndpointParser` 逻辑)
|
||||
3. 用**方法指纹**(类文件 + 方法名 + 参数类型签名)匹配新旧接口
|
||||
3. 用**方法指纹**(类文件 + 方法名 + 参数槽位,如 `0:query,1:query`;不含类型与绑定名)匹配新旧接口
|
||||
4. 指纹相同且 URI 不同 → **修改路径**
|
||||
5. 仅旧有新无 → **删除**;仅新有旧无 → **新增**
|
||||
|
||||
|
||||
@@ -12,12 +12,20 @@ import com.codechecker.model.FieldInfo;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 接口入参 diff(普通参数 + RequestBody 嵌套 Dto 字段),与类变更 FieldDiffEngine 解耦。
|
||||
* 接口入参 diff(普通参数 + RequestBody 嵌套 Dto 字段)。
|
||||
*
|
||||
* path/query 规则:
|
||||
* - 形参名+类型相同,仅绑定名变 → 重命名
|
||||
* - 形参名+绑定名相同,仅类型变 → 类型变更
|
||||
* - 仅形参名变(绑定名不变)→ 不通知
|
||||
* - 类型与绑定名同时变,或三者都变 → 先删除后新增
|
||||
*/
|
||||
public class ParameterDiffEngine {
|
||||
private final NestedDtoFieldParser nestedDtoFieldParser;
|
||||
@@ -28,38 +36,143 @@ public class ParameterDiffEngine {
|
||||
}
|
||||
|
||||
public List<ParameterChange> diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException {
|
||||
Map<String, MethodParameterSnapshot> oldParams = toParamMap(oldSnap);
|
||||
Map<String, MethodParameterSnapshot> newParams = toParamMap(newSnap);
|
||||
List<ParameterChange> changes = new ArrayList<>();
|
||||
changes.addAll(diffBodyParams(oldSnap, newSnap));
|
||||
changes.addAll(diffBindingParams(oldSnap, newSnap));
|
||||
return changes;
|
||||
}
|
||||
|
||||
private List<ParameterChange> diffBodyParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap)
|
||||
throws IOException {
|
||||
Map<String, MethodParameterSnapshot> oldParams = filterBySource(oldSnap, "body");
|
||||
Map<String, MethodParameterSnapshot> newParams = filterBySource(newSnap, "body");
|
||||
List<ParameterChange> changes = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<String, MethodParameterSnapshot> entry : newParams.entrySet()) {
|
||||
MethodParameterSnapshot oldParam = oldParams.get(entry.getKey());
|
||||
MethodParameterSnapshot newParam = entry.getValue();
|
||||
if (oldParam == null) {
|
||||
changes.addAll(addedChanges(newParam));
|
||||
} else if ("body".equals(newParam.getSource())) {
|
||||
changes.addAll(addedBodyChanges(newParam));
|
||||
} else {
|
||||
changes.addAll(diffBodyDto(oldParam, newParam));
|
||||
} else if (!oldParam.getType().equals(newParam.getType())) {
|
||||
changes.add(ParameterChange.modified(
|
||||
newParam.getName(),
|
||||
newParam.getType(),
|
||||
newParam.getDescription(),
|
||||
oldParam.getType() + " → " + newParam.getType(),
|
||||
newParam.getSource(),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, MethodParameterSnapshot> entry : oldParams.entrySet()) {
|
||||
if (!newParams.containsKey(entry.getKey())) {
|
||||
changes.addAll(removedChanges(entry.getValue()));
|
||||
changes.addAll(removedBodyChanges(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
private List<ParameterChange> diffBindingParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) {
|
||||
Map<String, MethodParameterSnapshot> oldParams = filterBindingParams(oldSnap);
|
||||
Map<String, MethodParameterSnapshot> newParams = filterBindingParams(newSnap);
|
||||
List<ParameterChange> changes = new ArrayList<>();
|
||||
|
||||
List<MethodParameterSnapshot> unmatchedOld = new ArrayList<>();
|
||||
List<MethodParameterSnapshot> unmatchedNew = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<String, MethodParameterSnapshot> entry : newParams.entrySet()) {
|
||||
MethodParameterSnapshot oldParam = oldParams.get(entry.getKey());
|
||||
MethodParameterSnapshot newParam = entry.getValue();
|
||||
if (oldParam == null) {
|
||||
unmatchedNew.add(newParam);
|
||||
} else if (!oldParam.getType().equals(newParam.getType())) {
|
||||
changes.add(ParameterChange.modified(
|
||||
newParam.displayName(),
|
||||
newParam.getType(),
|
||||
newParam.getDescription(),
|
||||
oldParam.getType() + " → " + newParam.getType(),
|
||||
newParam.getSource(),
|
||||
null, null, null));
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, MethodParameterSnapshot> entry : oldParams.entrySet()) {
|
||||
if (!newParams.containsKey(entry.getKey())) {
|
||||
unmatchedOld.add(entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
pairRenamedBindingParams(unmatchedOld, unmatchedNew, changes);
|
||||
|
||||
for (MethodParameterSnapshot removed : unmatchedOld) {
|
||||
changes.add(ParameterChange.removed(
|
||||
removed.displayName(), removed.getType(), removed.getDescription(),
|
||||
removed.getSource(), null, null, null));
|
||||
}
|
||||
for (MethodParameterSnapshot added : unmatchedNew) {
|
||||
changes.add(ParameterChange.added(
|
||||
added.displayName(), added.getType(), added.getDescription(),
|
||||
added.getSource(), null, null, null));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** 形参名+类型相同,仅绑定名变化 → 重命名 */
|
||||
private void pairRenamedBindingParams(List<MethodParameterSnapshot> unmatchedOld,
|
||||
List<MethodParameterSnapshot> unmatchedNew,
|
||||
List<ParameterChange> changes) {
|
||||
Set<MethodParameterSnapshot> pairedOld = new HashSet<>();
|
||||
Set<MethodParameterSnapshot> pairedNew = new HashSet<>();
|
||||
|
||||
for (MethodParameterSnapshot oldParam : unmatchedOld) {
|
||||
if (pairedOld.contains(oldParam)) {
|
||||
continue;
|
||||
}
|
||||
for (MethodParameterSnapshot newParam : unmatchedNew) {
|
||||
if (pairedNew.contains(newParam)) {
|
||||
continue;
|
||||
}
|
||||
if (!oldParam.getSource().equals(newParam.getSource())) {
|
||||
continue;
|
||||
}
|
||||
if (!oldParam.getName().equals(newParam.getName())) {
|
||||
continue;
|
||||
}
|
||||
if (!oldParam.getType().equals(newParam.getType())) {
|
||||
continue;
|
||||
}
|
||||
changes.add(ParameterChange.renamed(
|
||||
oldParam.getBindingName(),
|
||||
newParam.getBindingName(),
|
||||
newParam.getType(),
|
||||
newParam.getDescription(),
|
||||
newParam.getSource(),
|
||||
null, null, null));
|
||||
pairedOld.add(oldParam);
|
||||
pairedNew.add(newParam);
|
||||
break;
|
||||
}
|
||||
}
|
||||
unmatchedOld.removeIf(pairedOld::contains);
|
||||
unmatchedNew.removeIf(pairedNew::contains);
|
||||
}
|
||||
|
||||
private Map<String, MethodParameterSnapshot> filterBindingParams(EndpointSnapshot snap) {
|
||||
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
|
||||
for (MethodParameterSnapshot p : snap.getParameters()) {
|
||||
if (isBindingParam(p)) {
|
||||
map.put(p.identityKey(), p);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, MethodParameterSnapshot> filterBySource(EndpointSnapshot snap, String source) {
|
||||
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
|
||||
for (MethodParameterSnapshot p : snap.getParameters()) {
|
||||
if (source.equals(p.getSource())) {
|
||||
map.put(p.identityKey(), p);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private boolean isBindingParam(MethodParameterSnapshot param) {
|
||||
return "path".equals(param.getSource()) || "query".equals(param.getSource());
|
||||
}
|
||||
|
||||
private List<ParameterChange> diffBodyDto(MethodParameterSnapshot oldParam,
|
||||
MethodParameterSnapshot newParam) throws IOException {
|
||||
List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFields(oldParam.getDtoClassName());
|
||||
@@ -91,45 +204,29 @@ public class ParameterDiffEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private List<ParameterChange> addedChanges(MethodParameterSnapshot param) throws IOException {
|
||||
if ("body".equals(param.getSource())) {
|
||||
List<ParameterChange> list = new ArrayList<>();
|
||||
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) {
|
||||
list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(),
|
||||
"body", param.getName(), param.getDtoClassName(), field.getPath()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
return List.of(ParameterChange.added(param.getName(), param.getType(), param.getDescription(),
|
||||
param.getSource(), null, null, null));
|
||||
}
|
||||
|
||||
private List<ParameterChange> removedChanges(MethodParameterSnapshot param) throws IOException {
|
||||
if ("body".equals(param.getSource())) {
|
||||
List<ParameterChange> list = new ArrayList<>();
|
||||
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) {
|
||||
list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(),
|
||||
"body", param.getName(), param.getDtoClassName(), field.getPath()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
return List.of(ParameterChange.removed(param.getName(), param.getType(), param.getDescription(),
|
||||
param.getSource(), null, null, null));
|
||||
}
|
||||
|
||||
private Map<String, MethodParameterSnapshot> toParamMap(EndpointSnapshot snap) {
|
||||
Map<String, MethodParameterSnapshot> map = new LinkedHashMap<>();
|
||||
for (MethodParameterSnapshot p : snap.getParameters()) {
|
||||
map.put(p.identityKey(), p);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private List<FieldInfo> toFieldInfo(List<NestedFieldInfo> nested) {
|
||||
List<FieldInfo> list = new ArrayList<>();
|
||||
for (NestedFieldInfo info : nested) {
|
||||
list.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription()));
|
||||
private List<ParameterChange> addedBodyChanges(MethodParameterSnapshot param) throws IOException {
|
||||
List<ParameterChange> list = new ArrayList<>();
|
||||
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) {
|
||||
list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(),
|
||||
"body", param.getName(), param.getDtoClassName(), field.getPath()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<ParameterChange> removedBodyChanges(MethodParameterSnapshot param) throws IOException {
|
||||
List<ParameterChange> list = new ArrayList<>();
|
||||
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) {
|
||||
list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(),
|
||||
"body", param.getName(), param.getDtoClassName(), field.getPath()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<FieldInfo> toFieldInfo(List<NestedFieldInfo> nested) {
|
||||
List<FieldInfo> result = new ArrayList<>();
|
||||
for (NestedFieldInfo info : nested) {
|
||||
result.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.codechecker.api.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 单个 HTTP/Feign 接口快照。
|
||||
@@ -32,9 +31,13 @@ public class EndpointSnapshot {
|
||||
|
||||
public static String buildFingerprint(String sourceFile, String methodName,
|
||||
List<MethodParameterSnapshot> parameters) {
|
||||
String sig = parameters.stream()
|
||||
.map(p -> p.getType())
|
||||
.collect(Collectors.joining(","));
|
||||
StringBuilder sig = new StringBuilder();
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
if (i > 0) {
|
||||
sig.append(',');
|
||||
}
|
||||
sig.append(parameters.get(i).fingerprintSlotKey(i));
|
||||
}
|
||||
return sourceFile + "#" + methodName + "#" + sig;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,17 @@ package com.codechecker.api.model;
|
||||
*/
|
||||
public class MethodParameterSnapshot {
|
||||
private final String name;
|
||||
private final String bindingName;
|
||||
private final String type;
|
||||
private final String source;
|
||||
private final boolean required;
|
||||
private final String description;
|
||||
private final String dtoClassName;
|
||||
|
||||
public MethodParameterSnapshot(String name, String type, String source,
|
||||
public MethodParameterSnapshot(String name, String bindingName, String type, String source,
|
||||
boolean required, String description, String dtoClassName) {
|
||||
this.name = name;
|
||||
this.bindingName = bindingName == null || bindingName.isBlank() ? name : bindingName;
|
||||
this.type = type;
|
||||
this.source = source;
|
||||
this.required = required;
|
||||
@@ -25,6 +27,11 @@ public class MethodParameterSnapshot {
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 对外绑定名(@PathVariable / @RequestParam 的 value/name,缺省为形参名) */
|
||||
public String getBindingName() {
|
||||
return bindingName;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
@@ -46,7 +53,24 @@ public class MethodParameterSnapshot {
|
||||
return dtoClassName;
|
||||
}
|
||||
|
||||
/** 接口指纹槽位:序号 + 参数来源,不含类型与绑定名,避免类型/绑定变更导致误配对 */
|
||||
public String fingerprintSlotKey(int index) {
|
||||
return index + ":" + source;
|
||||
}
|
||||
|
||||
/** path/query 按绑定名匹配,避免仅 Java 形参重命名误报 */
|
||||
public String identityKey() {
|
||||
if ("path".equals(source) || "query".equals(source)) {
|
||||
return source + ":" + bindingName;
|
||||
}
|
||||
return source + ":" + name;
|
||||
}
|
||||
|
||||
/** 通知展示名:path/query 展示绑定名 */
|
||||
public String displayName() {
|
||||
if ("path".equals(source) || "query".equals(source)) {
|
||||
return bindingName;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,9 @@ public class ApiChangeNotifier {
|
||||
case RENAMED:
|
||||
tag = MarkdownStyles.colorWarning("[重命名]");
|
||||
break;
|
||||
case MODIFIED:
|
||||
tag = MarkdownStyles.colorWarning(change.isBodyField() ? "[修改]" : "[类型变更]");
|
||||
break;
|
||||
default:
|
||||
tag = MarkdownStyles.colorWarning("[修改]");
|
||||
break;
|
||||
@@ -214,19 +217,23 @@ public class ApiChangeNotifier {
|
||||
? MarkdownStyles.colorComment("(无说明)")
|
||||
: MarkdownStyles.colorComment(change.getDescription());
|
||||
StringBuilder line = new StringBuilder();
|
||||
line.append(MarkdownStyles.quoteLine(tag + " " + name + " 说明: " + desc));
|
||||
if (change.getChangeType() == ParameterChange.ChangeType.RENAMED) {
|
||||
line = new StringBuilder();
|
||||
line.append(MarkdownStyles.quoteLine(tag + " "
|
||||
+ MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName())) + " → "
|
||||
+ MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName()))
|
||||
+ " 说明: " + desc));
|
||||
line.append(tag).append(" ")
|
||||
.append(MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName()))).append(" → ")
|
||||
.append(MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName())))
|
||||
.append(" 说明: ").append(desc);
|
||||
} else {
|
||||
line.append(tag).append(" ").append(name).append(" 说明: ").append(desc);
|
||||
}
|
||||
appendParameterType(line, change);
|
||||
return MarkdownStyles.quoteLine(line.toString());
|
||||
}
|
||||
|
||||
private void appendParameterType(StringBuilder line, ParameterChange change) {
|
||||
String typePart = resolveTypePart(change);
|
||||
if (!typePart.isBlank()) {
|
||||
return line + "\n" + MarkdownStyles.quoteKv("类型", typePart);
|
||||
line.append(" 类型: ").append(typePart);
|
||||
}
|
||||
return line.toString();
|
||||
}
|
||||
|
||||
private String formatUriWithMethod(String httpMethod, String uri, boolean isNew) {
|
||||
|
||||
@@ -106,6 +106,7 @@ public class EndpointSnapshotParser {
|
||||
}
|
||||
|
||||
private List<MethodParameterSnapshot> extractParameters(MethodDeclaration method) {
|
||||
Map<String, String> paramDescriptions = MethodParamJavadocExtractor.extract(method);
|
||||
List<MethodParameterSnapshot> params = new ArrayList<>();
|
||||
for (Parameter parameter : method.getParameters()) {
|
||||
String typeName = TypeNameUtils.typeToString(parameter.getType());
|
||||
@@ -114,20 +115,43 @@ public class EndpointSnapshotParser {
|
||||
continue;
|
||||
}
|
||||
String source = resolveParamSource(parameter);
|
||||
String paramName = parameter.getNameAsString();
|
||||
String bindingName = resolveBindingName(parameter, source, paramName);
|
||||
boolean required = resolveRequired(parameter, source);
|
||||
String dtoName = "body".equals(source) ? simple : "";
|
||||
String description = paramDescriptions.getOrDefault(paramName, "");
|
||||
params.add(new MethodParameterSnapshot(
|
||||
parameter.getNameAsString(),
|
||||
paramName,
|
||||
bindingName,
|
||||
typeName,
|
||||
source,
|
||||
required,
|
||||
"",
|
||||
description,
|
||||
dtoName
|
||||
));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private String resolveBindingName(Parameter parameter, String source, String paramName) {
|
||||
if (!"path".equals(source) && !"query".equals(source)) {
|
||||
return paramName;
|
||||
}
|
||||
String annName = "path".equals(source) ? "PathVariable" : "RequestParam";
|
||||
for (AnnotationExpr ann : parameter.getAnnotations()) {
|
||||
if (!annName.equals(ann.getNameAsString())) {
|
||||
continue;
|
||||
}
|
||||
List<String> bindings = readStringArray(ann, "value", "name");
|
||||
for (String binding : bindings) {
|
||||
if (binding != null && !binding.isBlank()) {
|
||||
return binding;
|
||||
}
|
||||
}
|
||||
}
|
||||
return paramName;
|
||||
}
|
||||
|
||||
private String resolveParamSource(Parameter parameter) {
|
||||
for (AnnotationExpr ann : parameter.getAnnotations()) {
|
||||
String name = ann.getNameAsString();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.codechecker.api.parser;
|
||||
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.comments.JavadocComment;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 从方法 Javadoc 的 @param 标签提取形参说明(按 Java 形参名匹配)。
|
||||
*/
|
||||
public final class MethodParamJavadocExtractor {
|
||||
private static final Pattern PARAM_TAG = Pattern.compile(
|
||||
"@param\\s+(\\w+)\\s+(.+?)(?=\\s*@param\\s+|\\s*@return\\s+|\\s*@throws\\s+|\\s*@see\\s+|\\s*\\*/|$)",
|
||||
Pattern.DOTALL);
|
||||
|
||||
private MethodParamJavadocExtractor() {
|
||||
}
|
||||
|
||||
public static Map<String, String> extract(MethodDeclaration method) {
|
||||
Map<String, String> descriptions = new HashMap<>();
|
||||
if (method == null) {
|
||||
return descriptions;
|
||||
}
|
||||
Optional<JavadocComment> javadoc = method.getJavadocComment();
|
||||
if (javadoc.isEmpty()) {
|
||||
return descriptions;
|
||||
}
|
||||
String raw = javadoc.get().getContent();
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return descriptions;
|
||||
}
|
||||
String normalized = raw.replace('\r', '\n');
|
||||
Matcher matcher = PARAM_TAG.matcher(normalized);
|
||||
while (matcher.find()) {
|
||||
String paramName = matcher.group(1).trim();
|
||||
String desc = cleanDescription(matcher.group(2));
|
||||
if (!paramName.isBlank()) {
|
||||
descriptions.put(paramName, desc);
|
||||
}
|
||||
}
|
||||
return descriptions;
|
||||
}
|
||||
|
||||
private static String cleanDescription(String text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String line : text.split("\n")) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.startsWith("*")) {
|
||||
trimmed = trimmed.substring(1).trim();
|
||||
}
|
||||
if (!trimmed.isEmpty()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
sb.append(trimmed);
|
||||
}
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user