Skip to content

Commit

Permalink
Merge pull request #3455 from onflow/bastian/add-string-contains
Browse files Browse the repository at this point in the history
  • Loading branch information
turbolent authored Jul 9, 2024
2 parents 8565154 + 1fcafe0 commit 156dd3b
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 1 deletion.
83 changes: 83 additions & 0 deletions runtime/interpreter/value.go
Original file line number Diff line number Diff line change
Expand Up @@ -1379,6 +1379,21 @@ func (v *StringValue) GetMember(interpreter *Interpreter, locationRange Location
},
)

case sema.StringTypeContainsFunctionName:
return NewBoundHostFunctionValue(
interpreter,
v,
sema.StringTypeContainsFunctionType,
func(invocation Invocation) Value {
other, ok := invocation.Arguments[0].(*StringValue)
if !ok {
panic(errors.NewUnreachableError())
}

return v.Contains(invocation.Interpreter, other)
},
)

case sema.StringTypeDecodeHexFunctionName:
return NewBoundHostFunctionValue(
interpreter,
Expand Down Expand Up @@ -1688,6 +1703,74 @@ func (v *StringValue) ForEach(
}
}

func (v *StringValue) IsBoundaryStart(start int) bool {
v.prepareGraphemes()
return v.isGraphemeBoundaryStartPrepared(start)
}

func (v *StringValue) isGraphemeBoundaryStartPrepared(start int) bool {

for {
boundaryStart, _ := v.graphemes.Positions()
if start == boundaryStart {
return true
} else if boundaryStart > start {
return false
}

if !v.graphemes.Next() {
return false
}
}
}

func (v *StringValue) IsBoundaryEnd(end int) bool {
v.prepareGraphemes()
return v.isGraphemeBoundaryEndPrepared(end)
}

func (v *StringValue) isGraphemeBoundaryEndPrepared(end int) bool {

for {
_, boundaryEnd := v.graphemes.Positions()
if end == boundaryEnd {
return true
} else if boundaryEnd > end {
return false
}

if !v.graphemes.Next() {
return false
}
}
}

func (v *StringValue) Contains(inter *Interpreter, other *StringValue) BoolValue {

// Meter computation as if the string was iterated.
// This is a conservative over-estimation.
inter.ReportComputation(common.ComputationKindLoop, uint(len(v.Str)*len(other.Str)))

v.prepareGraphemes()

for start := 0; start < len(v.Str); start++ {

start = strings.Index(v.Str[start:], other.Str)
if start < 0 {
break
}

if v.isGraphemeBoundaryStartPrepared(start) &&
v.isGraphemeBoundaryEndPrepared(start+len(other.Str)) {

return TrueValue
}
}

return FalseValue

}

type StringValueIterator struct {
graphemes *uniseg.Graphemes
}
Expand Down
82 changes: 82 additions & 0 deletions runtime/interpreter/value_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4391,3 +4391,85 @@ func TestValue_ConformsToStaticType(t *testing.T) {
})

}

func TestStringIsBoundaryStart(t *testing.T) {

t.Parallel()

test := func(s string, i int, expected bool) {

name := fmt.Sprintf("%s, %d", s, i)

t.Run(name, func(t *testing.T) {
str := NewUnmeteredStringValue(s)
assert.Equal(t, expected, str.IsBoundaryStart(i))
})
}

test("", 0, true)
test("a", 0, true)
test("a", 1, false)
test("ab", 1, true)

// πŸ‡ͺπŸ‡ΈπŸ‡ͺπŸ‡ͺ ("ES", "EE")
flagESflagEE := "\U0001F1EA\U0001F1F8\U0001F1EA\U0001F1EA"
require.Len(t, flagESflagEE, 16)
test(flagESflagEE, 0, true)
test(flagESflagEE, 1, false)
test(flagESflagEE, 2, false)
test(flagESflagEE, 3, false)
test(flagESflagEE, 4, false)
test(flagESflagEE, 5, false)
test(flagESflagEE, 6, false)
test(flagESflagEE, 7, false)

test(flagESflagEE, 8, true)
test(flagESflagEE, 9, false)
test(flagESflagEE, 10, false)
test(flagESflagEE, 11, false)
test(flagESflagEE, 12, false)
test(flagESflagEE, 13, false)
test(flagESflagEE, 14, false)
test(flagESflagEE, 15, false)
}

func TestStringIsBoundaryEnd(t *testing.T) {

t.Parallel()

test := func(s string, i int, expected bool) {

name := fmt.Sprintf("%s, %d", s, i)

t.Run(name, func(t *testing.T) {
str := NewUnmeteredStringValue(s)
assert.Equal(t, expected, str.IsBoundaryEnd(i))
})
}

test("", 0, true)
test("a", 0, true)
test("a", 1, true)
test("ab", 1, true)

// πŸ‡ͺπŸ‡ΈπŸ‡ͺπŸ‡ͺ ("ES", "EE")
flagESflagEE := "\U0001F1EA\U0001F1F8\U0001F1EA\U0001F1EA"
require.Len(t, flagESflagEE, 16)
test(flagESflagEE, 0, true)
test(flagESflagEE, 1, false)
test(flagESflagEE, 2, false)
test(flagESflagEE, 3, false)
test(flagESflagEE, 4, false)
test(flagESflagEE, 5, false)
test(flagESflagEE, 6, false)
test(flagESflagEE, 7, false)

test(flagESflagEE, 8, true)
test(flagESflagEE, 9, false)
test(flagESflagEE, 10, false)
test(flagESflagEE, 11, false)
test(flagESflagEE, 12, false)
test(flagESflagEE, 13, false)
test(flagESflagEE, 14, false)
test(flagESflagEE, 15, false)
}
24 changes: 24 additions & 0 deletions runtime/sema/string_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ func init() {
StringTypeReplaceAllFunctionType,
StringTypeReplaceAllFunctionDocString,
),
NewUnmeteredPublicFunctionMember(
t,
StringTypeContainsFunctionName,
StringTypeContainsFunctionType,
stringTypeContainsFunctionDocString,
),
})
}
}
Expand Down Expand Up @@ -170,6 +176,24 @@ It does not modify the original string.
If either of the parameters are out of the bounds of the string, or the indices are invalid (` + "`from > upTo`" + `), then the function will fail
`

var StringTypeContainsFunctionType = NewSimpleFunctionType(
FunctionPurityView,
[]Parameter{
{
Label: ArgumentLabelNotRequired,
Identifier: "other",
TypeAnnotation: StringTypeAnnotation,
},
},
BoolTypeAnnotation,
)

const StringTypeContainsFunctionName = "contains"

const stringTypeContainsFunctionDocString = `
Returns true if this string contains the given other string as a substring.
`

const StringTypeReplaceAllFunctionName = "replaceAll"
const StringTypeReplaceAllFunctionDocString = `
Returns a new string after replacing all the occurrences of parameter ` + "`of` with the parameter `with`" + `.
Expand Down
45 changes: 45 additions & 0 deletions runtime/tests/checker/string_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -534,3 +534,48 @@ func TestCheckStringReplaceAllTypeMissingArgumentLabelWith(t *testing.T) {

assert.IsType(t, &sema.MissingArgumentLabelError{}, errs[0])
}

func TestCheckStringContains(t *testing.T) {

t.Parallel()

t.Run("missing argument", func(t *testing.T) {

t.Parallel()

_, err := ParseAndCheck(t, `
let a = "abcdef"
let x: Bool = a.contains()
`)

errs := RequireCheckerErrors(t, err, 1)

assert.IsType(t, &sema.InsufficientArgumentsError{}, errs[0])
})

t.Run("wrong argument type", func(t *testing.T) {

t.Parallel()

_, err := ParseAndCheck(t, `
let a = "abcdef"
let x: Bool = a.contains(1)
`)

errs := RequireCheckerErrors(t, err, 1)

assert.IsType(t, &sema.TypeMismatchError{}, errs[0])
})

t.Run("valid", func(t *testing.T) {

t.Parallel()

_, err := ParseAndCheck(t, `
let a = "abcdef"
let x: Bool = a.contains("abc")
`)

require.NoError(t, err)
})
}
5 changes: 4 additions & 1 deletion runtime/tests/interpreter/interpreter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1130,7 +1130,10 @@ func TestInterpretStringSlicing(t *testing.T) {
}

runTest := func(test test) {
t.Run("", func(t *testing.T) {

name := fmt.Sprintf("%s, %d, %d", test.str, test.from, test.to)

t.Run(name, func(t *testing.T) {

t.Parallel()

Expand Down
81 changes: 81 additions & 0 deletions runtime/tests/interpreter/string_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -643,3 +643,84 @@ func TestInterpretStringReplaceAll(t *testing.T) {
testCase(t, "testEmptyOf", interpreter.NewUnmeteredStringValue("1a1b1c1"))
testCase(t, "testNoMatch", interpreter.NewUnmeteredStringValue("pqrS;asdf"))
}

func TestInterpretStringContains(t *testing.T) {

t.Parallel()

type test struct {
str string
subStr string
result bool
}

tests := []test{
{"abcdef", "", true},
{"abcdef", "a", true},
{"abcdef", "ab", true},
{"abcdef", "ac", false},
{"abcdef", "b", true},
{"abcdef", "bc", true},
{"abcdef", "bcd", true},
{"abcdef", "c", true},
{"abcdef", "cd", true},
{"abcdef", "cdef", true},
{"abcdef", "cdefg", false},
{"abcdef", "abcdef", true},
{"abcdef", "abcdefg", false},

// U+1F476 U+1F3FB is πŸ‘ΆπŸ»
{" \\u{1F476}\\u{1F3FB} ascii \\u{D}\\u{A}", " \\u{1F476}", false},
{" \\u{1F476}\\u{1F3FB} ascii \\u{D}\\u{A}", "\\u{1F3FB}", false},
{" \\u{1F476}\\u{1F3FB} ascii \\u{D}\\u{A}", " \\u{1F476}\\u{1F3FB}", true},
{" \\u{1F476}\\u{1F3FB} ascii \\u{D}\\u{A}", "\\u{1F476}\\u{1F3FB}", true},
{" \\u{1F476}\\u{1F3FB} ascii \\u{D}\\u{A}", "\\u{1F476}\\u{1F3FB} ", true},
{" \\u{1F476}\\u{1F3FB} ascii \\u{D}\\u{A}", "\\u{D}", false},
{" \\u{1F476}\\u{1F3FB} ascii \\u{D}\\u{A}", "\\u{A}", false},
{" \\u{1F476}\\u{1F3FB} ascii \\u{D}\\u{A}", " ascii ", true},

// πŸ‡ͺπŸ‡ΈπŸ‡ͺπŸ‡ͺ ("ES", "EE") contains πŸ‡ͺπŸ‡Έ("ES")
{"\\u{1F1EA}\\u{1F1F8}\\u{1F1EA}\\u{1F1EA}", "\\u{1F1EA}\\u{1F1F8}", true},
// πŸ‡ͺπŸ‡ΈπŸ‡ͺπŸ‡ͺ ("ES", "EE") contains πŸ‡ͺπŸ‡ͺ ("EE")
{"\\u{1F1EA}\\u{1F1F8}\\u{1F1EA}\\u{1F1EA}", "\\u{1F1EA}\\u{1F1EA}", true},
// πŸ‡ͺπŸ‡ΈπŸ‡ͺπŸ‡ͺ ("ES", "EE") does NOT contain πŸ‡ΈπŸ‡ͺ ("SE")
{"\\u{1F1EA}\\u{1F1F8}\\u{1F1EA}\\u{1F1EA}", "\\u{1F1F8}\\u{1F1EA}", false},
// neither prefix nor suffix of codepoints are valid
{"\\u{1F1EA}\\u{1F1F8}\\u{1F1EA}\\u{1F1EA}", "\\u{1F1EA}\\u{1F1F8}\\u{1F1EA}", false},
{"\\u{1F1EA}\\u{1F1F8}\\u{1F1EA}\\u{1F1EA}", "\\u{1F1F8}\\u{1F1EA}\\u{1F1EA}", false},
}

runTest := func(test test) {

name := fmt.Sprintf("%s, %s", test.str, test.subStr)

t.Run(name, func(t *testing.T) {

t.Parallel()

inter := parseCheckAndInterpret(t,
fmt.Sprintf(
`
fun test(): Bool {
let s = "%s"
return s.contains("%s")
}
`,
test.str,
test.subStr,
),
)

value, err := inter.Invoke("test")
require.NoError(t, err)

require.IsType(t, interpreter.BoolValue(true), value)
actual := value.(interpreter.BoolValue)
require.Equal(t, test.result, bool(actual))
})
}

for _, test := range tests {
runTest(test)
}
}

0 comments on commit 156dd3b

Please sign in to comment.