diff --git a/runtime/interpreter/value.go b/runtime/interpreter/value.go index 33740d0b27..3b7580bc23 100644 --- a/runtime/interpreter/value.go +++ b/runtime/interpreter/value.go @@ -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, @@ -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 } diff --git a/runtime/interpreter/value_test.go b/runtime/interpreter/value_test.go index d2f9937364..1a0f82c6fe 100644 --- a/runtime/interpreter/value_test.go +++ b/runtime/interpreter/value_test.go @@ -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) +} diff --git a/runtime/sema/string_type.go b/runtime/sema/string_type.go index 70bb4122fd..5ce6c2f1fb 100644 --- a/runtime/sema/string_type.go +++ b/runtime/sema/string_type.go @@ -123,6 +123,12 @@ func init() { StringTypeReplaceAllFunctionType, StringTypeReplaceAllFunctionDocString, ), + NewUnmeteredPublicFunctionMember( + t, + StringTypeContainsFunctionName, + StringTypeContainsFunctionType, + stringTypeContainsFunctionDocString, + ), }) } } @@ -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`" + `. diff --git a/runtime/tests/checker/string_test.go b/runtime/tests/checker/string_test.go index 1291a66441..756b6c9ec7 100644 --- a/runtime/tests/checker/string_test.go +++ b/runtime/tests/checker/string_test.go @@ -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) + }) +} diff --git a/runtime/tests/interpreter/interpreter_test.go b/runtime/tests/interpreter/interpreter_test.go index 5e014f5c67..384ee93d1d 100644 --- a/runtime/tests/interpreter/interpreter_test.go +++ b/runtime/tests/interpreter/interpreter_test.go @@ -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() diff --git a/runtime/tests/interpreter/string_test.go b/runtime/tests/interpreter/string_test.go index 5a770cb674..b342e14ec9 100644 --- a/runtime/tests/interpreter/string_test.go +++ b/runtime/tests/interpreter/string_test.go @@ -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) + } +}