1// SPDX-License-Identifier: MIT
2
3package expr
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "math"
11 "os"
12 "path/filepath"
13 "sort"
14 "strconv"
15 "strings"
16 "testing"
17)
18
19// The cross-language fixtures of actions/languageservices
20// (testdata/languageservices, see SOURCE there) are shared by the TypeScript
21// and the C# implementations. This runner reproduces
22// expressions/src/xlang.test.ts of that repository:
23//
24// - contexts are revived into expression data, and their names are the
25// context names given to the parser; no extension functions are declared;
26// - a lexing error is compared with the error message of the lexer;
27// - a parsing error with "<message>. Located at position <column+1> within
28// expression: <expr>";
29// - an evaluation error with "<message>. Located within expression: <expr>";
30// - error expectations match by substring, result kinds by name, result
31// values by their JSON.stringify form (NaN and Infinity are null).
32//
33// The only deliberate difference from the TypeScript runner is that cases
34// marked options.skip = ["typescript"] are run too: they are skipped there
35// because the TypeScript port lacks a feature, not because the fixture is
36// wrong. Fixtures that this package cannot satisfy are listed in
37// xlangKnownDifferences with the reason.
38
39type xlangResult struct {
40 Kind string `json:"kind"`
41 Value json.RawMessage `json:"value"`
42}
43
44type xlangErr struct {
45 Kind string `json:"kind"`
46 Value string `json:"value"`
47}
48
49type xlangCase struct {
50 Expr string `json:"expr"`
51 Result *xlangResult `json:"result"`
52 Err *xlangErr `json:"err"`
53 Contexts json.RawMessage `json:"contexts"`
54 Options struct {
55 Skip []string `json:"skip"`
56 } `json:"options"`
57}
58
59// xlangKnownDifferences maps "file: group (expr prefix)" keys to the reason
60// the fixture is not satisfied. Keep it empty if possible.
61var xlangKnownDifferences = map[string]string{
62 "coerce_string: number#10": "negative zero: the fixture says \"0\" (as the newer engine, src/Sdk/Expressions/EvaluationResult.cs); the legacy engine that runs today prints \"-0\" (DTExpressions2/EvaluationResult.cs, no -0 special case), checked with gen/runner and engines.mjs",
63 "format: format#19": "negative zero (format('{0}', -0)), same reason as coerce_string number#10",
64 "format: format#20": "negative zero (-0.0 literal), same reason",
65 "tojson: basics#4": "negative zero (tojson(-0)), same reason: toJson writes numbers with ConvertToString",
66 "coerce_string: number#11": "negative zero (-0.0 literal), same reason as number#10",
67 "case: case#27": "the runner reports an even number of case() arguments on the '(' token (position 5), not on the name: ExpressionParser.FlushTopEndParameters passes the popped StartParameters token; checked by running the runner's code",
68 "syntax-errors: memory-errors#0": "memory accounting differs from the runner's EvaluationMemory",
69}
70
71// xlangKind is the kind name the fixtures use.
72func xlangKind(v Value) string {
73 switch v.Kind() {
74 case KindArray:
75 return "Array"
76 case KindBoolean:
77 return "Boolean"
78 case KindNull:
79 return "Null"
80 case KindNumber:
81 return "Number"
82 case KindString:
83 return "String"
84 }
85 return "Object"
86}
87
88// jsonStringify mimics JSON.stringify(v) (no indentation) for a value.
89func jsonStringify(b *strings.Builder, v Value) {
90 switch x := v.(type) {
91 case nil, Null:
92 b.WriteString("null")
93 case Boolean:
94 b.WriteString(strconv.FormatBool(bool(x)))
95 case Number:
96 f := float64(x)
97 if math.IsNaN(f) || math.IsInf(f, 0) {
98 b.WriteString("null")
99 } else {
100 b.WriteString(jsNumberToString(f))
101 }
102 case String:
103 s, _ := json.Marshal(string(x))
104 b.Write(s)
105 case *Array:
106 b.WriteByte('[')
107 for i, it := range x.Items() {
108 if i > 0 {
109 b.WriteByte(',')
110 }
111 jsonStringify(b, it)
112 }
113 b.WriteByte(']')
114 case *Dictionary:
115 b.WriteByte('{')
116 for i, p := range x.Pairs() {
117 if i > 0 {
118 b.WriteByte(',')
119 }
120 k, _ := json.Marshal(p.Key)
121 b.Write(k)
122 b.WriteByte(':')
123 jsonStringify(b, p.Value)
124 }
125 b.WriteByte('}')
126 }
127}
128
129func stringifyValue(v Value) string {
130 var b strings.Builder
131 jsonStringify(&b, v)
132 return b.String()
133}
134
135// xlangContexts returns the revived contexts and their names, sorted.
136func xlangContexts(tb testing.TB, raw json.RawMessage) (map[string]Value, []string) {
137 tb.Helper()
138 ctx := map[string]Value{}
139 if len(raw) == 0 {
140 return ctx, nil
141 }
142 var m map[string]json.RawMessage
143 if err := json.Unmarshal(raw, &m); err != nil {
144 tb.Fatalf("contexts: %v", err)
145 }
146 var names []string
147 for k, r := range m {
148 ctx[k] = mustJSON(tb, string(r))
149 names = append(names, k)
150 }
151 sort.Strings(names)
152 return ctx, names
153}
154
155// runXlangCase returns "" when the case passes, else the reason it fails.
156func runXlangCase(tb testing.TB, c xlangCase) string {
157 ctxValues, names := xlangContexts(tb, c.Contexts)
158 expectErr := func(kind string) bool { return c.Err != nil && c.Err.Kind == kind }
159
160 // Lex.
161 _, err := Lex(c.Expr)
162 if err != nil {
163 if expectErr("lexing") {
164 if strings.Contains(err.Error(), c.Err.Value) {
165 return ""
166 }
167 return fmt.Sprintf("lexing error %q does not contain %q", err.Error(), c.Err.Value)
168 }
169 return "unexpected error lexing expression: " + err.Error()
170 }
171 if expectErr("lexing") {
172 return "expected error lexing expression, but got none"
173 }
174
175 // Parse.
176 node, err := Parse(c.Expr, names, nil)
177 if err != nil {
178 if expectErr("parsing") {
179 var pe *Error
180 if !errors.As(err, &pe) {
181 return fmt.Sprintf("parse error has type %T", err)
182 }
183 // The TypeScript runner builds the suffix from a column; this
184 // package, like the C# runner, already has it in the message.
185 full := pe.Message
186 if !strings.Contains(full, ". Located at position ") {
187 full = fmt.Sprintf("%s. Located at position %d within expression: %s", pe.Message, pe.Pos.Column+1, c.Expr)
188 }
189 if strings.Contains(full, c.Err.Value) {
190 return ""
191 }
192 return fmt.Sprintf("parsing error %q does not contain %q", full, c.Err.Value)
193 }
194 return "unexpected error parsing expression: " + err.Error()
195 }
196 if expectErr("parsing") {
197 return "expected error parsing expression, but got none"
198 }
199
200 // Evaluate. An empty expression is null.
201 var result Value = Null{}
202 if node != nil {
203 result, err = Evaluate(context.Background(), node, EvalContext{Contexts: ctxValues})
204 }
205 if err != nil {
206 if expectErr("evaluation") {
207 full := err.Error() + ". Located within expression: " + c.Expr
208 if strings.Contains(full, c.Err.Value) {
209 return ""
210 }
211 return fmt.Sprintf("evaluation error %q does not contain %q", full, c.Err.Value)
212 }
213 return "unexpected error evaluating expression: " + err.Error()
214 }
215 if expectErr("evaluation") {
216 return "expected error evaluating expression, but got none"
217 }
218 if c.Result == nil {
219 return "fixture has neither result nor err"
220 }
221 if k := xlangKind(result); k != c.Result.Kind {
222 return fmt.Sprintf("kind: got %s, want %s", k, c.Result.Kind)
223 }
224 want := stringifyValue(mustJSON(tb, string(c.Result.Value)))
225 if got := stringifyValue(result); got != want {
226 return fmt.Sprintf("value: got %s, want %s", got, want)
227 }
228 return ""
229}
230
231func TestLanguageServicesFixtures(t *testing.T) {
232 files, err := filepath.Glob("testdata/languageservices/*.json")
233 if err != nil || len(files) != 28 {
234 t.Fatalf("fixture files: %d, %v", len(files), err)
235 }
236 var total, skippedByTS, known, failed int
237 for _, file := range files {
238 name := strings.TrimSuffix(filepath.Base(file), ".json")
239 data, err := os.ReadFile(file)
240 if err != nil {
241 t.Fatal(err)
242 }
243 var groups map[string][]xlangCase
244 if err := json.Unmarshal(data, &groups); err != nil {
245 t.Fatalf("%s: %v", file, err)
246 }
247 groupNames := make([]string, 0, len(groups))
248 for g := range groups {
249 groupNames = append(groupNames, g)
250 }
251 sort.Strings(groupNames)
252 for _, g := range groupNames {
253 for i, c := range groups[g] {
254 total++
255 for _, s := range c.Options.Skip {
256 if s == "typescript" {
257 skippedByTS++
258 }
259 }
260 key := name + ": " + g
261 label := fmt.Sprintf("%s/%s/%d", name, g, i)
262 reason, isKnown := xlangKnownDifferences[fmt.Sprintf("%s#%d", key, i)]
263 t.Run(label, func(t *testing.T) {
264 msg := runXlangCase(t, c)
265 switch {
266 case msg == "" && isKnown:
267 t.Errorf("listed as a known difference (%s) but passes", reason)
268 case msg != "" && isKnown:
269 known++
270 t.Logf("known difference (%s): %s [%s]", reason, msg, c.Expr)
271 case msg != "":
272 failed++
273 t.Errorf("%s [%s]", msg, c.Expr)
274 }
275 })
276 }
277 }
278 }
279 t.Logf("%d fixtures in %d files (%d skipped by the TypeScript runner and run here), %d known differences, %d failures", total, len(files), skippedByTS, known, failed)
280 if total != 1020 {
281 t.Errorf("expected 1020 fixtures, found %d", total)
282 }
283}