Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions skills/easy-yapi-assistant/rule-contexts.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions skills/easy-yapi-assistant/rule-contexts.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,13 @@ Type: `com.itangcent.easyapi.core.util.RuleToolUtils` — Rule utility methods.
- `substringBetween(str: String?, open: String?, close: String?): String?`
- `substringsBetween(str: String?, open: String?, close: String?): Array<String>?`
- `swapCase(str: String?): String?`
- `toCamelCase(str: String?): String?`
- `toCamelCase(str: String?, capitalizeFirstLetter: Boolean, delimiters: CharArray): String?`
- `toJson(obj: Any?): String?`
- `toKebabCase(str: String?): String?`
- `toLowerDotCase(str: String?): String?`
- `toPascalCase(str: String?): String?`
- `toSnakeCase(str: String?): String?`
- `today(): String`
- `uncapitalize(str: String?): String?`
- `upperCase(str: String?): String?`
Expand Down Expand Up @@ -378,6 +383,7 @@ Type: `com.itangcent.easyapi.core.rule.context.FieldContext` — PSI field conte
- `doc(tag: String): String?`
- `doc(tag: String, subTag: String): String?`
- `docs(tag: String): List<String>?`
- `fieldName(): String`
- `getExt(name: String): Any?`
- `getName(): String?`
- `hasAnn(name: String): Boolean`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.itangcent.easyapi.core.psi.model.ObjectModel
import com.itangcent.easyapi.core.psi.type.*
import com.itangcent.easyapi.core.rule.RuleKeys
import com.itangcent.easyapi.core.rule.engine.RuleEngine
import com.itangcent.easyapi.core.util.BeanPropertyNames
import com.itangcent.easyapi.core.util.json.GsonUtils
import java.util.concurrent.atomic.AtomicInteger

Expand Down Expand Up @@ -631,7 +632,7 @@ class DefaultPsiClassHelper(private val project: Project) : PsiClassHelper {
for (method in psiClass.allMethods) {
if (isFromObject(method)) continue
if (isGetter(method)) {
val propertyName = getPropertyNameFromGetter(method.name)
val propertyName = BeanPropertyNames.fromAccessor(method.name)
if (fieldNames.add(propertyName)) {
val returnType = method.returnType ?: continue
val methodContext = resolveContextForMember(method, psiClass, genericContext)
Expand All @@ -650,7 +651,7 @@ class DefaultPsiClassHelper(private val project: Project) : PsiClassHelper {
for (method in psiClass.allMethods) {
if (isFromObject(method)) continue
if (isSetter(method)) {
val propertyName = getPropertyNameFromSetter(method.name)
val propertyName = BeanPropertyNames.fromAccessor(method.name)
if (fieldNames.add(propertyName)) {
val paramType = method.parameterList.parameters.firstOrNull()?.type ?: continue
val methodContext = resolveContextForMember(method, psiClass, genericContext)
Expand Down Expand Up @@ -812,22 +813,6 @@ class DefaultPsiClassHelper(private val project: Project) : PsiClassHelper {
return true
}

private fun getPropertyNameFromGetter(name: String): String {
return when {
name.startsWith("get") && name.length > 3 -> name[3].lowercaseChar() + name.substring(4)
name.startsWith("is") && name.length > 2 -> name[2].lowercaseChar() + name.substring(3)
else -> name
}
}

private fun getPropertyNameFromSetter(name: String): String {
return if (name.startsWith("set") && name.length > 3) {
name[3].lowercaseChar() + name.substring(4)
} else {
name
}
}

private suspend fun buildFieldModel(
resolvedType: ResolvedType,
engine: RuleEngine,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.itangcent.easyapi.core.psi.type.ResolvedParam
import com.itangcent.easyapi.core.psi.type.ResolvedType
import com.itangcent.easyapi.core.psi.type.SpecialTypeHandler
import com.itangcent.easyapi.core.psi.type.TypeResolver
import com.itangcent.easyapi.core.util.BeanPropertyNames
import kotlinx.coroutines.runBlocking

//region Context Type Interfaces
Expand Down Expand Up @@ -164,14 +165,17 @@ interface MethodContext : ItContext {
fun isSynchronized(): Boolean
fun isNative(): Boolean
/**
* Returns the underlying field name derived from this method's name.
* Returns the property name derived from this method's name, following
* `java.beans.Introspector.decapitalize`:
*
* - `getCode` → `code`
* - `getCode` / `setCode` → `code`
* - `isActive` → `active`
* - `name` (or any non-getter/non-is method) → returned as-is
* - `getURLValue` → `URLValue` (two leading capitals are preserved)
* - `name` (or any non-accessor method) → returned as-is
*
* Useful for mapping getter methods to their backing instance fields
* (e.g. Jackson `@JsonValue` on a getter).
* Keeping the acronym intact lets a `URLValue` field and its accessors
* resolve to the same property. Together with [FieldContext.fieldName],
* this lets rules treat a field and its accessors as one property.
*/
fun fieldName(): String
}
Expand All @@ -184,8 +188,18 @@ interface MethodContext : ItContext {
* - Type access
* - Containing class access
* - Field modifier checks
* - Property-name access shared with [MethodContext]
*/
interface FieldContext : ItContext {
/**
* Returns the model property name for this context.
*
* For a field this is its declared name. Together with
* [MethodContext.fieldName], which strips the accessor prefix, this lets a
* rule read the property name without branching on [ItContext.contextType].
*/
fun fieldName(): String = name()

fun type(): ScriptTypeContext
fun jsonType(): ScriptTypeContext
fun containingClass(): ScriptPsiClassContext?
Expand Down Expand Up @@ -485,14 +499,7 @@ open class ScriptPsiMethodContext(context: RuleContext) : ScriptItContext(contex

override fun isNative(): Boolean = readSync { psiMethod().hasModifierProperty(com.intellij.psi.PsiModifier.NATIVE) }

override fun fieldName(): String {
val n = name()
return when {
n.startsWith("get") && n.length > 3 -> n.substring(3, 4).lowercase() + n.substring(4)
n.startsWith("is") && n.length > 2 -> n.substring(2, 3).lowercase() + n.substring(3)
else -> n
}
}
override fun fieldName(): String = BeanPropertyNames.fromAccessor(name())

override fun canonicalText(): String {
val cls = readSync { psiMethod().containingClass?.qualifiedName } ?: ""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.itangcent.easyapi.core.util

/**
* Derives JavaBeans property names from accessor method names.
*
* Decapitalization follows `java.beans.Introspector.decapitalize`: a name whose
* first two characters are both upper-case is left untouched. Lowering only the
* first character would turn `getURLValue()` into `uRLValue`, which no longer
* matches the `URLValue` field and makes the model export both as separate
* properties (the issue #1492 class of bug).
*
* This is also the standard bean naming Jackson 3 defaults to; Jackson 2 needs
* `MapperFeature.USE_STD_BEAN_NAMING` to opt in.
*/
object BeanPropertyNames {

fun decapitalize(name: String): String {
if (name.length > 1 && name[0].isUpperCase() && name[1].isUpperCase()) {
return name
}
return name.replaceFirstChar { it.lowercaseChar() }
}

/**
* Strips a `get` / `is` / `set` prefix and decapitalizes the remainder.
* A non-accessor method name is returned unchanged.
*/
fun fromAccessor(methodName: String): String = when {
methodName.startsWith("get") && methodName.length > 3 ->
decapitalize(methodName.substring(3))

methodName.startsWith("is") && methodName.length > 2 ->
decapitalize(methodName.substring(2))

methodName.startsWith("set") && methodName.length > 3 ->
decapitalize(methodName.substring(3))

else -> methodName
}
}
117 changes: 117 additions & 0 deletions src/main/kotlin/com/itangcent/easyapi/core/util/RuleToolUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,15 @@ object RuleToolUtils {

private val TO_LINE_PATTERN = Pattern.compile("[A-Z]+")

/**
* Legacy helper matching Jackson's `SnakeCaseStrategy` (`wasPrevTranslated`),
* copied by the v2 `#[Jackson_SnakeCaseStrategy]` block: consecutive
* upper-case letters stay glued together, so `URLValue` -> `urlvalue`.
* Kept for rules that still call it by name. Naming-strategy extensions use
* [toSnakeCase] instead, which follows the mainstream acronym handling
* shared by .NET, Newtonsoft.Json, Rails, Pydantic, lodash and Jackson's
* `KebabCaseStrategy` (`url_value`).
*/
fun camel2Underline(str: String?): String? {
if (str.isNullOrBlank()) return str
val matcher = TO_LINE_PATTERN.matcher(str)
Expand All @@ -247,6 +256,114 @@ object RuleToolUtils {
return buffer.toString()
}

// ── name transforms ───────────────────────────────────────────────

private val NAME_SEPARATORS = setOf('_', '-', ' ', '.')

/**
* Splits a property name into lower-case words at separators (`_`, `-`,
* `.`, space), camel humps and acronym boundaries (`URLValue` -> `url`,
* `value`). This is the word boundary shared by Jackson's
* `KebabCaseStrategy` / `LowerDotCaseStrategy`, System.Text.Json,
* Newtonsoft.Json, Rails, Pydantic, lodash and change-case: uppercase runs
* form one word, except that their last letter starts the next word when a
* lower-case letter follows.
*
* Jackson's `SnakeCaseStrategy` is the outlier; it does not split acronyms,
* and that legacy behaviour lives in [camel2Underline].
*/
private fun nameWords(str: String): List<String> {
val words = mutableListOf<String>()
val word = StringBuilder()
var upperCount = 0

fun flush() {
if (word.isNotEmpty()) {
words.add(word.toString())
word.setLength(0)
}
}

for (ch in str) {
if (ch in NAME_SEPARATORS) {
flush()
upperCount = 0
continue
}
val lower = ch.lowercaseChar()
if (lower != ch) {
if (upperCount == 0) flush()
upperCount++
} else {
if (upperCount > 1 && word.isNotEmpty()) {
// The last upper-case letter of an acronym starts a new word.
val last = word[word.length - 1]
word.setLength(word.length - 1)
flush()
word.append(last)
}
upperCount = 0
}
word.append(lower)
}
flush()
return words
}

/**
* Normalizes a property name to lower camelCase while keeping existing camel
* humps: `user_name` -> `userName`, `displayName` -> `displayName`,
* `URLValue` -> `urlValue`.
*/
fun toCamelCase(str: String?): String? {
if (str.isNullOrBlank()) return str
val words = nameWords(str)
if (words.isEmpty()) return str
return words.mapIndexed { index, word ->
if (index == 0) word else word.replaceFirstChar { it.uppercaseChar() }
}.joinToString("")
}

/**
* Normalizes a property name to kebab-case: `userName` -> `user-name`,
* `URLValue` -> `url-value`. Existing separators are folded into `-`
* (`user_name` -> `user-name`).
*/
fun toKebabCase(str: String?): String? {
if (str.isNullOrBlank()) return str
return nameWords(str).joinToString("-").ifEmpty { str }
}

/**
* Normalizes a property name to snake_case: `userName` -> `user_name`,
* `URLValue` -> `url_value`. Acronyms stay one word and are split from the
* following word, unlike the legacy [camel2Underline] (`urlvalue`).
*/
fun toSnakeCase(str: String?): String? {
if (str.isNullOrBlank()) return str
return nameWords(str).joinToString("_").ifEmpty { str }
}

/**
* Normalizes a property name to UpperCamelCase: `user_name` -> `UserName`,
* `URLValue` -> `UrlValue`.
*/
fun toPascalCase(str: String?): String? {
if (str.isNullOrBlank()) return str
return nameWords(str).joinToString("") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}.ifEmpty { str }
}

/**
* Normalizes a property name to lower dot case: `userName` -> `user.name`,
* `URLValue` -> `url.value`.
*/
fun toLowerDotCase(str: String?): String? {
if (str.isNullOrBlank()) return str
return nameWords(str).joinToString(".").ifEmpty { str }
}

fun removePrefix(str: String?, prefix: String?): String? = prefix?.let { str?.removePrefix(it) }

fun removeSuffix(str: String?, suffix: String?): String? = suffix?.let { str?.removeSuffix(it) }
Expand Down
11 changes: 11 additions & 0 deletions src/main/resources/extensions/field.name.camel-case.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
code: field.name.camel-case
description: Normalize field names to lower camelCase (user_name -> userName, displayName -> displayName)
default-enabled: false
---

# Mainstream lowerCamelCase: separators, camel humps and acronym boundaries are
# word boundaries (user_name -> userName, URLValue -> urlValue).
field.name=groovy:```
return tool.toCamelCase(it.fieldName())
```
10 changes: 10 additions & 0 deletions src/main/resources/extensions/field.name.capitalize.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
code: field.name.capitalize
description: Uppercase the first letter of every exported field name (barcode -> Barcode)
default-enabled: false
---

# Mirrors Jackson's UpperCamelCaseStrategy from the v2 recommended configuration.
field.name=groovy:```
return tool.capitalize(it.fieldName())
```
12 changes: 12 additions & 0 deletions src/main/resources/extensions/field.name.kebab-case.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
code: field.name.kebab-case
description: Convert camelCase field names to kebab-case (userName -> user-name)
default-enabled: false
---

# Mainstream kebab-case: acronym boundaries are split (URLValue -> url-value).
# Matches Jackson's KebabCaseStrategy as well as .NET, Newtonsoft.Json, Rails,
# Pydantic and lodash.
field.name=groovy:```
return tool.toKebabCase(it.fieldName())
```
11 changes: 11 additions & 0 deletions src/main/resources/extensions/field.name.lower-dot-case.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
code: field.name.lower-dot-case
description: Convert field names to lower dot case (URLValue -> url.value)
default-enabled: false
---

# Matches Jackson's LowerDotCaseStrategy: acronyms are split at word boundaries
# in the same way as the kebab-case and snake-case strategies.
field.name=groovy:```
return tool.toLowerDotCase(it.fieldName())
```
11 changes: 11 additions & 0 deletions src/main/resources/extensions/field.name.pascal-case.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
code: field.name.pascal-case
description: Convert field names to UpperCamelCase, keeping acronyms as one word (URLValue -> UrlValue)
default-enabled: false
---

# Mainstream UpperCamelCase (PascalCase): shares the word splitter used by the
# other field.name strategies (user_name -> UserName, URLValue -> UrlValue).
field.name=groovy:```
return tool.toPascalCase(it.fieldName())
```
13 changes: 13 additions & 0 deletions src/main/resources/extensions/field.name.snake-case.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
code: field.name.snake-case
description: Convert field names to snake_case, keeping acronyms as one word (URLValue -> url_value)
default-enabled: false
---

# Mainstream snake_case: separators, camel humps and acronym boundaries are all
# word boundaries (displayName -> display_name, URLValue -> url_value).
# Jackson's SnakeCaseStrategy is the outlier (urlvalue); that legacy behaviour
# remains available as tool.camel2Underline for existing rules.
field.name=groovy:```
return tool.toSnakeCase(it.fieldName())
```
11 changes: 11 additions & 0 deletions src/main/resources/extensions/field.name.uncapitalize.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
code: field.name.uncapitalize
description: Lowercase the first letter of every exported field name (Barcode -> barcode)
default-enabled: false
---

# Generated models often declare `public String Barcode` alongside `getBarcode()`.
# Normalizing both to `barcode` lets the model builder keep one property (#1492).
field.name=groovy:```
return tool.uncapitalize(it.fieldName())
```
Loading
Loading