From ca000eb4a5a65ecbca3411d55c5fcbcd4007c9d6 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 5 Oct 2026 23:07:51 +0100 Subject: [PATCH] String positions and indices are clamped before they reach a pointer or a count slice with its end at or before its start computed a negative count, which resize and memcpy took as a huge size and copied past the result (#27). It now returns "", as in JavaScript; slice does not swap the way substring does. The end parameter of slice and substring, and the position of endsWith and lastIndexOf, defaulted to this.length and so took its unsigned type, though lib.d.ts declares int: a negative argument became huge, every `< 0` check was dead, and slice(1, -3) returned "bcdef". They are int now. Positions are clamped to [0, length] before they become a pointer into the string: startsWith, includes and indexOf read before the string for a negative position, and endsWith and lastIndexOf read after its terminator for one past the end. indexOf past the end returned the length for any search string; now only "" is found there, as in JavaScript. includes and lastIndexOf return "not found" for a null search string, as indexOf does (includes passed null to strstr; lastIndexOf returned the position). trim and trimEnd passed the last kept character as substring's exclusive end and dropped it (" ab ".trim() was "a"), and a string of spaces kept all but one of them. Fixes #27 Co-Authored-By: Claude Opus 5.5 --- src/lib.ts | 122 ++++++++++++++++++++++++++++------------- tests/string_bounds.ts | 73 ++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 38 deletions(-) create mode 100644 tests/string_bounds.ts diff --git a/src/lib.ts b/src/lib.ts index ea03238..5c62175 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -677,14 +677,24 @@ namespace __String { return newString; } - export function endsWith(this: string, searchString: string, endPosition = this.length): boolean { + export function endsWith(this: string, searchString: string, endPosition: int = this.length): boolean { // a null check: "" is falsy, and an empty search string is found if (searchString == null) { return false; } - const lenstr = endPosition; + // the end is clamped to [0, length]: past the end the suffix was read after the terminator + let lenstr = endPosition; + if (lenstr < 0) + { + lenstr = 0; + } + else if (lenstr > this.length) + { + lenstr = this.length; + } + const lensuffix = searchString.length; if (lensuffix > lenstr) { @@ -694,12 +704,24 @@ namespace __String { return strncmp(Ref(this[lenstr - lensuffix]), searchString, lensuffix) == 0; } - export function includes(this: string, searchString: string, position = 0): boolean { - if (position >= this.length) + export function includes(this: string, searchString: string, position = 0): boolean { + // a null check: "" is falsy, and an empty search string is found + if (searchString == null) { return false; } + // the position is clamped to [0, length]: a negative one read before the string, and at + // the end only "" is found, at the terminator + if (position < 0) + { + position = 0; + } + else if (position > this.length) + { + position = this.length; + } + return strstr(Ref(this[position]), searchString) != null; } @@ -710,11 +732,16 @@ namespace __String { return -1; } - if (position >= this.length) + // the position is clamped to [0, length], as in includes; at the end only "" is found + if (position < 0) { - return this.length; + position = 0; } - + else if (position > this.length) + { + position = this.length; + } + const found = strstr(Ref(this[position]), searchString); if (found == null) { @@ -729,10 +756,22 @@ namespace __String { return true; } - export function lastIndexOf(this: string, searchString: string, position = this.length): int { - if (!searchString) + export function lastIndexOf(this: string, searchString: string, position: int = this.length): int { + // the position is clamped to [0, length] first: past the end the search read after the + // terminator, and "" is found at the clamped position + if (position < 0) { - return position; + position = 0; + } + else if (position > this.length) + { + position = this.length; + } + + // a null check: "" is falsy + if (searchString == null) + { + return -1; } const searchStringLen = searchString.length; @@ -741,11 +780,6 @@ namespace __String { return position; } - if (position < 0) - { - position = 0; - } - for (let i = position; i >= 0; i--) { const found = strncmp(Ref(this[i]), searchString, searchStringLen); @@ -916,7 +950,7 @@ namespace __String { return regexp.search(this); } - export function slice(this: string, indexStart: int, indexEnd = this.length): string { + export function slice(this: string, indexStart: int, indexEnd: int = this.length): string { if (indexStart < 0) { if (-this.length <= indexStart) { @@ -936,7 +970,13 @@ namespace __String { } } else if (indexEnd >= this.length) { indexEnd = this.length; - } + } + + // an end at or before the start is empty - slice does not swap, as substring does; the + // count was negative and the copy ran past the result (#27) + if (indexEnd <= indexStart) { + return ""; + } const count = indexEnd - indexStart; const newString = "".clone().resize(count); @@ -1003,10 +1043,16 @@ namespace __String { return false; } + // a negative position is 0: it read before the string + if (position < 0) + { + position = 0; + } + return strncmp(Ref(this[position]), searchString, lensuffix) == 0; } - export function substring(this: string, indexStart: int, indexEnd = this.length): string { + export function substring(this: string, indexStart: int, indexEnd: int = this.length): string { if (indexStart < 0) { indexStart = 0; } else if (indexStart >= this.length) { @@ -1070,36 +1116,36 @@ namespace __String { return this; } - export function trim(this: string): string { - let start = 0; - for (let i = 0; i < this.length; i++) { - if (!isspace(this[i])) { start = i; break; } + // the first character that is not a space, or the length when every one is + function trimmedStart(this: string): int { + for (let i: int = 0; i < this.length; i++) { + if (!isspace(this[i])) return i; } - let end = this.length - 1; - for (let i = this.length - 1; i >= 0; i--) { - if (!isspace(this[i])) { end = i; break; } + return this.length; + } + + // one past the last character that is not a space, or 0 when every one is: substring's end is + // exclusive, and the end used to be the last kept character, so it was dropped + function trimmedEnd(this: string): int { + for (let i: int = this.length - 1; i >= 0; i--) { + if (!isspace(this[i])) return i + 1; } - return this.substring(start, end); + return 0; } - export function trimStart(this: string): string { - let start = 0; - for (let i = 0; i < this.length; i++) { - if (!isspace(this[i])) { start = i; break; } - } + export function trim(this: string): string { + const start = this.trimmedStart(); + return start == this.length ? "" : this.substring(start, this.trimmedEnd()); + } - return this.substring(start); + export function trimStart(this: string): string { + return this.substring(this.trimmedStart()); } export function trimEnd(this: string): string { - let end = this.length - 1; - for (let i = this.length - 1; i >= 0; i--) { - if (!isspace(this[i])) { end = i; break; } - } - - return this.substring(0, end); + return this.substring(0, this.trimmedEnd()); } export function valueOf(this: string): string { diff --git a/tests/string_bounds.ts b/tests/string_bounds.ts new file mode 100644 index 0000000..40d46f3 --- /dev/null +++ b/tests/string_bounds.ts @@ -0,0 +1,73 @@ +// A position or index outside the string is clamped, as in JavaScript, before it becomes a pointer +// into the string or a byte count: slice with its end before its start computed a negative count +// and copied past the result (#27); a negative position read before the string; a position past +// the end read after its terminator. trim, which dropped the last kept character, is here too. +function main() { + const s = "abcdef"; + + // slice: an end at or before the start is empty - slice does not swap, substring does + assert(s.slice(4, 1) == "", "slice(4, 1)"); + assert(s.slice(-1, -3) == "", "slice(-1, -3)"); + assert(s.slice(3, 3) == "", "slice(3, 3)"); + assert(s.slice(2, 4) == "cd", "slice(2, 4)"); + assert(s.slice(-3) == "def", "slice(-3)"); + assert(s.slice(-10, 2) == "ab", "slice(-10, 2)"); + assert(s.slice(4, 100) == "ef", "slice(4, 100)"); + assert(s.slice(10) == "", "slice(10)"); + assert(s.substring(4, 1) == "bcd", "substring(4, 1) swaps"); + + // a negative end counts from the end: the parameter was the type of `length`, unsigned, so a + // negative end became huge and was clamped to the length + assert(s.slice(1, -3) == "bc", "slice(1, -3)"); + assert(s.slice(-1, 3) == "", "slice(-1, 3)"); + assert(s.substring(1, -3) == "a", "substring(1, -3) is substring(0, 1)"); + assert(!s.endsWith("ab", -1), "endsWith('ab', -1)"); + + // startsWith: a negative position is 0 + assert(s.startsWith("ab", -5), "startsWith('ab', -5)"); + assert(!s.startsWith("b", -5), "startsWith('b', -5)"); + + // endsWith: the end position is clamped to [0, length] + assert(s.endsWith("ef", 100), "endsWith('ef', 100)"); + assert(s.endsWith("ab", 2), "endsWith('ab', 2)"); + assert(!s.endsWith("a", -5), "endsWith('a', -5)"); + assert(s.endsWith("", -5), "endsWith('', -5)"); + + // includes: a negative position is 0, one past the end finds only "" + assert(s.includes("a", -5), "includes('a', -5)"); + assert(!s.includes("f", 10), "includes('f', 10)"); + assert(s.includes("", 10), "includes('', 10)"); + + // indexOf: as includes, and past the end nothing but "" is found + assert(s.indexOf("b", -5) == 1, "indexOf('b', -5)"); + assert(s.indexOf("x", 10) == -1, "indexOf('x', 10)"); + assert(s.indexOf("f", 10) == -1, "indexOf('f', 10)"); + assert(s.indexOf("", 10) == 6, "indexOf('', 10)"); + + // lastIndexOf: the position is clamped to [0, length] + assert(s.lastIndexOf("a", 100) == 0, "lastIndexOf('a', 100)"); + assert(s.lastIndexOf("f", 100) == 5, "lastIndexOf('f', 100)"); + assert(s.lastIndexOf("", 100) == 6, "lastIndexOf('', 100)"); + assert(s.lastIndexOf("a", -5) == 0, "lastIndexOf('a', -5)"); + assert(s.lastIndexOf("b", -5) == -1, "lastIndexOf('b', -5)"); + + // padStart / padEnd: an empty pad string pads nothing + assert("x".padStart(5, "") == "x", "padStart(5, '')"); + assert("x".padEnd(5, "") == "x", "padEnd(5, '')"); + assert("x".padStart(3, "ab") == "abx", "padStart(3, 'ab')"); + assert("x".padEnd(4, "ab") == "xaba", "padEnd(4, 'ab')"); + + // trim: substring's end is exclusive, and the last kept character was passed as the end + assert(" ab ".trim() == "ab", "trim"); + assert("ab".trim() == "ab", "trim, nothing to trim"); + assert(" ".trim() == "", "trim, all spaces"); + assert("".trim() == "", "trim, empty"); + assert(" ab ".trimStart() == "ab ", "trimStart"); + assert(" ".trimStart() == "", "trimStart, all spaces"); + assert(" ab ".trimEnd() == " ab", "trimEnd"); + assert("ab".trimEnd() == "ab", "trimEnd, nothing to trim"); + assert(" ".trimEnd() == "", "trimEnd, all spaces"); + assert("".trimEnd() == "", "trimEnd, empty"); + + console.log("ALL DONE"); +}