diff --git a/docs/Range.md b/docs/Range.md index cf92d20..909d0e3 100644 --- a/docs/Range.md +++ b/docs/Range.md @@ -98,10 +98,11 @@ Exclusive end char ranges are supported too: | isEndInclusive | true for '..' | Bool | | isOpen | at any end | Bool | | isIntRange | both start and end are Int | Bool | -| start | | Bool | -| end | | Bool | +| start | | Any? | +| end | | Any? | | size | for finite ranges, see above | Long | | [] | see above | | -| | | | + +Ranges are also used with the `clamp(value, range)` function and the `value.clamp(range)` extension method to limit values within boundaries. [Iterable]: Iterable.md \ No newline at end of file diff --git a/docs/Real.md b/docs/Real.md index 2f63c7a..0e9b484 100644 --- a/docs/Real.md +++ b/docs/Real.md @@ -19,6 +19,7 @@ you can use it's class to ensure type: |-----------------|-------------------------------------------------------------|------| | `.roundToInt()` | round to nearest int like round(x) | Int | | `.toInt()` | convert integer part of real to `Int` dropping decimal part | Int | +| `.clamp(range)` | clamp value within range boundaries | Real | | | | | | | | | | | | | diff --git a/docs/math.md b/docs/math.md index 743d54a..d8a67a9 100644 --- a/docs/math.md +++ b/docs/math.md @@ -92,6 +92,7 @@ or transformed `Real` otherwise. | pow(x, y) | ${x^y}$ | | sqrt(x) | $ \sqrt {x}$ | | abs(x) | absolute value of x. Int if x is Int, Real otherwise | +| clamp(x, range) | limit x to be inside range boundaries | For example: @@ -102,6 +103,11 @@ For example: // abs() keeps the argument type: assert( abs(-1) is Int) assert( abs(-2.21) == 2.21 ) + + // clamp() limits value to the range: + assert( clamp(15, 0..10) == 10 ) + assert( clamp(-5, 0..10) == 0 ) + assert( 5.clamp(0..10) == 5 ) >>> void ## Scientific constant diff --git a/docs/whats_new.md b/docs/whats_new.md index e8ecf24..59bcddb 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -186,6 +186,26 @@ Key features: - **Structural Equality**: Transient fields are automatically ignored during `==` equality checks. - **Deserialization**: Transient constructor parameters with default values are correctly restored to those defaults upon restoration. +### Value Clamping (`clamp`) +A new `clamp()` function has been added to the standard library to limit a value within a specified range. It is available as both a global function and an extension method on all objects. + +```lyng +// Global function +clamp(15, 0..10) // returns 10 +clamp(-5, 0..10) // returns 0 + +// Extension method +val x = 15 +x.clamp(0..10) // returns 10 + +// Exclusive and open-ended ranges +15.clamp(0..<10) // returns 9 +15.clamp(..10) // returns 10 +-5.clamp(0..) // returns 0 +``` + +`clamp()` correctly handles inclusive (`..`) and exclusive (`..<`) ranges. For discrete types like `Int` and `Char`, clamping to an exclusive upper bound returns the previous value. + ## Tooling and Infrastructure ### CLI: Formatting Command diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt index abe7a72..82e4cef 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt @@ -139,7 +139,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { val e: Int = miniSource.offsetOf(d.range.end) if (offset >= s && offset <= e) { // For enum constant, we don't have detailed docs in MiniAst yet, but we can render a title - return "
")
diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt
index b33d199..b2f02a7 100644
--- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt
+++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt
@@ -121,12 +121,39 @@ class LyngLexer : LexerBase() {
// Number
if (ch.isDigit()) {
+ // Check for hex: 0x...
+ if (ch == '0' && i + 1 < endOffset && buffer[i + 1] == 'x') {
+ i += 2
+ while (i < endOffset && (buffer[i].isDigit() || buffer[i] in 'a'..'f' || buffer[i] in 'A'..'F')) i++
+ myTokenEnd = i
+ myTokenType = LyngTokenTypes.NUMBER
+ return
+ }
+
+ // Decimal or integer
i++
var hasDot = false
+ var hasE = false
while (i < endOffset) {
val c = buffer[i]
- if (c.isDigit()) { i++; continue }
- if (c == '.' && !hasDot) { hasDot = true; i++; continue }
+ if (c.isDigit() || c == '_') {
+ i++
+ continue
+ }
+ if (c == '.' && !hasDot && !hasE) {
+ // Check if it's a fractional part (must be followed by a digit)
+ if (i + 1 < endOffset && buffer[i + 1].isDigit()) {
+ hasDot = true
+ i++
+ continue
+ }
+ }
+ if ((c == 'e' || c == 'E') && !hasE) {
+ hasE = true
+ i++
+ if (i < endOffset && (buffer[i] == '+' || buffer[i] == '-')) i++
+ continue
+ }
break
}
myTokenEnd = i
@@ -161,6 +188,35 @@ class LyngLexer : LexerBase() {
// Punctuation
if (isPunct(ch)) {
i++
+ // Handle common multi-char operators for better highlighting
+ when (ch) {
+ '.' -> {
+ if (i < endOffset && buffer[i] == '.') {
+ i++
+ if (i < endOffset && (buffer[i] == '.' || buffer[i] == '<')) i++
+ }
+ }
+ '=' -> {
+ if (i < endOffset && (buffer[i] == '=' || buffer[i] == '>' || buffer[i] == '~')) {
+ i++
+ if (buffer[i - 1] == '=' && i < endOffset && buffer[i] == '=') i++
+ }
+ }
+ '+', '-', '*', '/', '%', '!', '<', '>', '&', '|', '?', ':', '^' -> {
+ if (i < endOffset) {
+ val next = buffer[i]
+ if (next == '=' || next == ch) {
+ i++
+ if (ch == '<' && next == '=' && i < endOffset && buffer[i] == '>') i++
+ if (ch == '!' && next == '=' && i < endOffset && buffer[i] == '=') i++
+ } else if (ch == '?' && (next == '.' || next == '[' || next == '(' || next == '{' || next == ':' || next == '?')) {
+ i++
+ } else if (ch == '-' && next == '>') {
+ i++
+ }
+ }
+ }
+ }
myTokenEnd = i
myTokenType = LyngTokenTypes.PUNCT
return
diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt
index c59831c..f65ef7d 100644
--- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt
+++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt
@@ -20,10 +20,7 @@ package net.sergeych.lyng
import kotlinx.coroutines.delay
import kotlinx.coroutines.yield
import net.sergeych.lyng.Script.Companion.defaultImportManager
-import net.sergeych.lyng.miniast.addConstDoc
-import net.sergeych.lyng.miniast.addFnDoc
-import net.sergeych.lyng.miniast.addVoidFnDoc
-import net.sergeych.lyng.miniast.type
+import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.obj.*
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.stdlib_included.rootLyng
@@ -162,6 +159,42 @@ class Script(
if (x is ObjInt) ObjInt(x.value.absoluteValue) else ObjReal(x.toDouble().absoluteValue)
}
+ addFnDoc(
+ "clamp",
+ doc = "Clamps the value within the specified range. If the value is outside the range, it is set to the nearest boundary. Respects inclusive/exclusive range ends.",
+ params = listOf(ParamDoc("value"), ParamDoc("range")),
+ moduleName = "lyng.stdlib"
+ ) {
+ val value = requiredArg(0)
+ val range = requiredArg(1)
+
+ var result = value
+ if (range.start != null && !range.start.isNull) {
+ if (result.compareTo(this, range.start) < 0) {
+ result = range.start
+ }
+ }
+ if (range.end != null && !range.end.isNull) {
+ val cmp = range.end.compareTo(this, result)
+ if (range.isEndInclusive) {
+ if (cmp < 0) result = range.end
+ } else {
+ if (cmp <= 0) {
+ if (range.end is ObjInt) {
+ result = ObjInt.of(range.end.value - 1)
+ } else if (range.end is ObjChar) {
+ result = ObjChar((range.end.value.code - 1).toChar())
+ } else {
+ // For types where we can't easily find "previous" value (like Real),
+ // we just return the exclusive boundary as a fallback.
+ result = range.end
+ }
+ }
+ }
+ }
+ result
+ }
+
addVoidFn("assert") {
val cond = requiredArg(0)
val message = if (args.size > 1)
diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt
index cfbcdfb..9bbad7f 100644
--- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt
+++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt
@@ -760,6 +760,38 @@ open class Obj {
) {
thisObj.toJson(this).toString().toObj()
}
+ addFnDoc(
+ name = "clamp",
+ doc = "Clamps this value within the specified range. If the value is outside the range, it is set to the nearest boundary. Respects inclusive/exclusive range ends.",
+ params = listOf(ParamDoc("range")),
+ moduleName = "lyng.stdlib"
+ ) {
+ val range = requiredArg(0)
+
+ var result = thisObj
+ if (range.start != null && !range.start.isNull) {
+ if (result.compareTo(this, range.start) < 0) {
+ result = range.start
+ }
+ }
+ if (range.end != null && !range.end.isNull) {
+ val cmp = range.end.compareTo(this, result)
+ if (range.isEndInclusive) {
+ if (cmp < 0) result = range.end
+ } else {
+ if (cmp <= 0) {
+ if (range.end is ObjInt) {
+ result = ObjInt.of(range.end.value - 1)
+ } else if (range.end is ObjChar) {
+ result = ObjChar((range.end.value.code - 1).toChar())
+ } else {
+ result = range.end
+ }
+ }
+ }
+ }
+ result
+ }
}
diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt
index 319bc77..bcc7fc6 100644
--- a/lynglib/src/commonTest/kotlin/ScriptTest.kt
+++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt
@@ -4986,5 +4986,41 @@ class ScriptTest {
assertEquals(["bar"], A().f2("bar"))
""")
}
+
+ @Test
+ fun testClamp() = runTest {
+ eval("""
+ // Global clamp
+ assertEquals(5, clamp(5, 0..10))
+ assertEquals(0, clamp(-5, 0..10))
+ assertEquals(10, clamp(15, 0..10))
+
+ // Extension clamp
+ assertEquals(5, 5.clamp(0..10))
+ assertEquals(0, (-5).clamp(0..10))
+ assertEquals(10, 15.clamp(0..10))
+
+ // Exclusive range
+ assertEquals(9, 15.clamp(0..<10))
+ assertEquals(0, (-5).clamp(0..<10))
+
+ // Open-ended range
+ assertEquals(10, 15.clamp(..10))
+ assertEquals(-5, (-5).clamp(..10))
+ assertEquals(5, 5.clamp(0..))
+ assertEquals(0, (-5).clamp(0..))
+
+ // Character range
+ assertEquals('e', 'e'.clamp('a'..'z'))
+ assertEquals('a', ' '.clamp('a'..'z'))
+ assertEquals('z', '}'.clamp('a'..'z'))
+ assertEquals('y', '}'.clamp('a'..<'z'))
+
+ // Real numbers (boundaries are inclusive in current impl for Real)
+ assertEquals(5.5, 5.5.clamp(0.0..10.0))
+ assertEquals(0.0, (-1.5).clamp(0.0..10.0))
+ assertEquals(10.0, 15.5.clamp(0.0..10.0))
+ """.trimIndent())
+ }
}