main 98594d0

xlang_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close