detached 9588af7

test(expr): replay the languageservices fixtures like xlang.test.ts

commit
9588af7799711387ab70d692f07f6ca5bfc5a074
author
brice · · 2026-10-12 17:10 UTC
parent
922360b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +276 -0
A expr/xlang_test.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
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+
39+type xlangResult struct {
40+ Kind string `json:"kind"`
41+ Value json.RawMessage `json:"value"`
42+}
43+
44+type xlangErr struct {
45+ Kind string `json:"kind"`
46+ Value string `json:"value"`
47+}
48+
49+type 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.
61+var xlangKnownDifferences = map[string]string{
62+ "syntax-errors: memory-errors#0": "memory accounting differs from the runner's EvaluationMemory",
63+ "syntax-errors: parsing-errors#25": "position is the column in the line, the runner reports the index in the expression",
64+ "syntax-errors: parsing-errors#26": "position is the column in the line, the runner reports the index in the expression",
65+ "syntax-errors: parsing-errors#27": "position is the column in the line, the runner reports the index in the expression",
66+ "syntax-errors: parsing-errors#28": "position is the column in the line, the runner reports the index in the expression",
67+}
68+
69+// xlangKind is the kind name the fixtures use.
70+func xlangKind(v Value) string {
71+ switch v.Kind() {
72+ case KindArray:
73+ return "Array"
74+ case KindBoolean:
75+ return "Boolean"
76+ case KindNull:
77+ return "Null"
78+ case KindNumber:
79+ return "Number"
80+ case KindString:
81+ return "String"
82+ }
83+ return "Object"
84+}
85+
86+// jsonStringify mimics JSON.stringify(v) (no indentation) for a value.
87+func jsonStringify(b *strings.Builder, v Value) {
88+ switch x := v.(type) {
89+ case nil, Null:
90+ b.WriteString("null")
91+ case Boolean:
92+ b.WriteString(strconv.FormatBool(bool(x)))
93+ case Number:
94+ f := float64(x)
95+ if math.IsNaN(f) || math.IsInf(f, 0) {
96+ b.WriteString("null")
97+ } else {
98+ b.WriteString(jsNumberToString(f))
99+ }
100+ case String:
101+ s, _ := json.Marshal(string(x))
102+ b.Write(s)
103+ case *Array:
104+ b.WriteByte('[')
105+ for i, it := range x.Items() {
106+ if i > 0 {
107+ b.WriteByte(',')
108+ }
109+ jsonStringify(b, it)
110+ }
111+ b.WriteByte(']')
112+ case *Dictionary:
113+ b.WriteByte('{')
114+ for i, p := range x.Pairs() {
115+ if i > 0 {
116+ b.WriteByte(',')
117+ }
118+ k, _ := json.Marshal(p.Key)
119+ b.Write(k)
120+ b.WriteByte(':')
121+ jsonStringify(b, p.Value)
122+ }
123+ b.WriteByte('}')
124+ }
125+}
126+
127+func stringifyValue(v Value) string {
128+ var b strings.Builder
129+ jsonStringify(&b, v)
130+ return b.String()
131+}
132+
133+// xlangContexts returns the revived contexts and their names, sorted.
134+func xlangContexts(tb testing.TB, raw json.RawMessage) (map[string]Value, []string) {
135+ tb.Helper()
136+ ctx := map[string]Value{}
137+ if len(raw) == 0 {
138+ return ctx, nil
139+ }
140+ var m map[string]json.RawMessage
141+ if err := json.Unmarshal(raw, &m); err != nil {
142+ tb.Fatalf("contexts: %v", err)
143+ }
144+ var names []string
145+ for k, r := range m {
146+ ctx[k] = mustJSON(tb, string(r))
147+ names = append(names, k)
148+ }
149+ sort.Strings(names)
150+ return ctx, names
151+}
152+
153+// runXlangCase returns "" when the case passes, else the reason it fails.
154+func runXlangCase(tb testing.TB, c xlangCase) string {
155+ ctxValues, names := xlangContexts(tb, c.Contexts)
156+ expectErr := func(kind string) bool { return c.Err != nil && c.Err.Kind == kind }
157+
158+ // Lex.
159+ _, err := Lex(c.Expr)
160+ if err != nil {
161+ if expectErr("lexing") {
162+ if strings.Contains(err.Error(), c.Err.Value) {
163+ return ""
164+ }
165+ return fmt.Sprintf("lexing error %q does not contain %q", err.Error(), c.Err.Value)
166+ }
167+ return "unexpected error lexing expression: " + err.Error()
168+ }
169+ if expectErr("lexing") {
170+ return "expected error lexing expression, but got none"
171+ }
172+
173+ // Parse.
174+ node, err := Parse(c.Expr, names, nil)
175+ if err != nil {
176+ if expectErr("parsing") {
177+ var pe *Error
178+ if !errors.As(err, &pe) {
179+ return fmt.Sprintf("parse error has type %T", err)
180+ }
181+ full := fmt.Sprintf("%s. Located at position %d within expression: %s", pe.Message, pe.Pos.Column+1, c.Expr)
182+ if strings.Contains(full, c.Err.Value) {
183+ return ""
184+ }
185+ return fmt.Sprintf("parsing error %q does not contain %q", full, c.Err.Value)
186+ }
187+ return "unexpected error parsing expression: " + err.Error()
188+ }
189+ if expectErr("parsing") {
190+ return "expected error parsing expression, but got none"
191+ }
192+
193+ // Evaluate. An empty expression is null.
194+ var result Value = Null{}
195+ if node != nil {
196+ result, err = Evaluate(context.Background(), node, EvalContext{Contexts: ctxValues})
197+ }
198+ if err != nil {
199+ if expectErr("evaluation") {
200+ full := err.Error() + ". Located within expression: " + c.Expr
201+ if strings.Contains(full, c.Err.Value) {
202+ return ""
203+ }
204+ return fmt.Sprintf("evaluation error %q does not contain %q", full, c.Err.Value)
205+ }
206+ return "unexpected error evaluating expression: " + err.Error()
207+ }
208+ if expectErr("evaluation") {
209+ return "expected error evaluating expression, but got none"
210+ }
211+ if c.Result == nil {
212+ return "fixture has neither result nor err"
213+ }
214+ if k := xlangKind(result); k != c.Result.Kind {
215+ return fmt.Sprintf("kind: got %s, want %s", k, c.Result.Kind)
216+ }
217+ want := stringifyValue(mustJSON(tb, string(c.Result.Value)))
218+ if got := stringifyValue(result); got != want {
219+ return fmt.Sprintf("value: got %s, want %s", got, want)
220+ }
221+ return ""
222+}
223+
224+func TestLanguageServicesFixtures(t *testing.T) {
225+ files, err := filepath.Glob("testdata/languageservices/*.json")
226+ if err != nil || len(files) != 28 {
227+ t.Fatalf("fixture files: %d, %v", len(files), err)
228+ }
229+ var total, skippedByTS, known, failed int
230+ for _, file := range files {
231+ name := strings.TrimSuffix(filepath.Base(file), ".json")
232+ data, err := os.ReadFile(file)
233+ if err != nil {
234+ t.Fatal(err)
235+ }
236+ var groups map[string][]xlangCase
237+ if err := json.Unmarshal(data, &groups); err != nil {
238+ t.Fatalf("%s: %v", file, err)
239+ }
240+ groupNames := make([]string, 0, len(groups))
241+ for g := range groups {
242+ groupNames = append(groupNames, g)
243+ }
244+ sort.Strings(groupNames)
245+ for _, g := range groupNames {
246+ for i, c := range groups[g] {
247+ total++
248+ for _, s := range c.Options.Skip {
249+ if s == "typescript" {
250+ skippedByTS++
251+ }
252+ }
253+ key := name + ": " + g
254+ label := fmt.Sprintf("%s/%s/%d", name, g, i)
255+ reason, isKnown := xlangKnownDifferences[fmt.Sprintf("%s#%d", key, i)]
256+ t.Run(label, func(t *testing.T) {
257+ msg := runXlangCase(t, c)
258+ switch {
259+ case msg == "" && isKnown:
260+ t.Errorf("listed as a known difference (%s) but passes", reason)
261+ case msg != "" && isKnown:
262+ known++
263+ t.Logf("known difference (%s): %s [%s]", reason, msg, c.Expr)
264+ case msg != "":
265+ failed++
266+ t.Errorf("%s [%s]", msg, c.Expr)
267+ }
268+ })
269+ }
270+ }
271+ }
272+ 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)
273+ if total != 1020 {
274+ t.Errorf("expected 1020 fixtures, found %d", total)
275+ }
276+}
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: MIT
2+
3+package expr
4+
5+import (
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+
39+type xlangResult struct {
40+ Kind string `json:"kind"`
41+ Value json.RawMessage `json:"value"`
42+}
43+
44+type xlangErr struct {
45+ Kind string `json:"kind"`
46+ Value string `json:"value"`
47+}
48+
49+type 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.
61+var xlangKnownDifferences = map[string]string{
62+ "syntax-errors: memory-errors#0": "memory accounting differs from the runner's EvaluationMemory",
63+ "syntax-errors: parsing-errors#25": "position is the column in the line, the runner reports the index in the expression",
64+ "syntax-errors: parsing-errors#26": "position is the column in the line, the runner reports the index in the expression",
65+ "syntax-errors: parsing-errors#27": "position is the column in the line, the runner reports the index in the expression",
66+ "syntax-errors: parsing-errors#28": "position is the column in the line, the runner reports the index in the expression",
67+}
68+
69+// xlangKind is the kind name the fixtures use.
70+func xlangKind(v Value) string {
71+ switch v.Kind() {
72+ case KindArray:
73+ return "Array"
74+ case KindBoolean:
75+ return "Boolean"
76+ case KindNull:
77+ return "Null"
78+ case KindNumber:
79+ return "Number"
80+ case KindString:
81+ return "String"
82+ }
83+ return "Object"
84+}
85+
86+// jsonStringify mimics JSON.stringify(v) (no indentation) for a value.
87+func jsonStringify(b *strings.Builder, v Value) {
88+ switch x := v.(type) {
89+ case nil, Null:
90+ b.WriteString("null")
91+ case Boolean:
92+ b.WriteString(strconv.FormatBool(bool(x)))
93+ case Number:
94+ f := float64(x)
95+ if math.IsNaN(f) || math.IsInf(f, 0) {
96+ b.WriteString("null")
97+ } else {
98+ b.WriteString(jsNumberToString(f))
99+ }
100+ case String:
101+ s, _ := json.Marshal(string(x))
102+ b.Write(s)
103+ case *Array:
104+ b.WriteByte('[')
105+ for i, it := range x.Items() {
106+ if i > 0 {
107+ b.WriteByte(',')
108+ }
109+ jsonStringify(b, it)
110+ }
111+ b.WriteByte(']')
112+ case *Dictionary:
113+ b.WriteByte('{')
114+ for i, p := range x.Pairs() {
115+ if i > 0 {
116+ b.WriteByte(',')
117+ }
118+ k, _ := json.Marshal(p.Key)
119+ b.Write(k)
120+ b.WriteByte(':')
121+ jsonStringify(b, p.Value)
122+ }
123+ b.WriteByte('}')
124+ }
125+}
126+
127+func stringifyValue(v Value) string {
128+ var b strings.Builder
129+ jsonStringify(&b, v)
130+ return b.String()
131+}
132+
133+// xlangContexts returns the revived contexts and their names, sorted.
134+func xlangContexts(tb testing.TB, raw json.RawMessage) (map[string]Value, []string) {
135+ tb.Helper()
136+ ctx := map[string]Value{}
137+ if len(raw) == 0 {
138+ return ctx, nil
139+ }
140+ var m map[string]json.RawMessage
141+ if err := json.Unmarshal(raw, &m); err != nil {
142+ tb.Fatalf("contexts: %v", err)
143+ }
144+ var names []string
145+ for k, r := range m {
146+ ctx[k] = mustJSON(tb, string(r))
147+ names = append(names, k)
148+ }
149+ sort.Strings(names)
150+ return ctx, names
151+}
152+
153+// runXlangCase returns "" when the case passes, else the reason it fails.
154+func runXlangCase(tb testing.TB, c xlangCase) string {
155+ ctxValues, names := xlangContexts(tb, c.Contexts)
156+ expectErr := func(kind string) bool { return c.Err != nil && c.Err.Kind == kind }
157+
158+ // Lex.
159+ _, err := Lex(c.Expr)
160+ if err != nil {
161+ if expectErr("lexing") {
162+ if strings.Contains(err.Error(), c.Err.Value) {
163+ return ""
164+ }
165+ return fmt.Sprintf("lexing error %q does not contain %q", err.Error(), c.Err.Value)
166+ }
167+ return "unexpected error lexing expression: " + err.Error()
168+ }
169+ if expectErr("lexing") {
170+ return "expected error lexing expression, but got none"
171+ }
172+
173+ // Parse.
174+ node, err := Parse(c.Expr, names, nil)
175+ if err != nil {
176+ if expectErr("parsing") {
177+ var pe *Error
178+ if !errors.As(err, &pe) {
179+ return fmt.Sprintf("parse error has type %T", err)
180+ }
181+ full := fmt.Sprintf("%s. Located at position %d within expression: %s", pe.Message, pe.Pos.Column+1, c.Expr)
182+ if strings.Contains(full, c.Err.Value) {
183+ return ""
184+ }
185+ return fmt.Sprintf("parsing error %q does not contain %q", full, c.Err.Value)
186+ }
187+ return "unexpected error parsing expression: " + err.Error()
188+ }
189+ if expectErr("parsing") {
190+ return "expected error parsing expression, but got none"
191+ }
192+
193+ // Evaluate. An empty expression is null.
194+ var result Value = Null{}
195+ if node != nil {
196+ result, err = Evaluate(context.Background(), node, EvalContext{Contexts: ctxValues})
197+ }
198+ if err != nil {
199+ if expectErr("evaluation") {
200+ full := err.Error() + ". Located within expression: " + c.Expr
201+ if strings.Contains(full, c.Err.Value) {
202+ return ""
203+ }
204+ return fmt.Sprintf("evaluation error %q does not contain %q", full, c.Err.Value)
205+ }
206+ return "unexpected error evaluating expression: " + err.Error()
207+ }
208+ if expectErr("evaluation") {
209+ return "expected error evaluating expression, but got none"
210+ }
211+ if c.Result == nil {
212+ return "fixture has neither result nor err"
213+ }
214+ if k := xlangKind(result); k != c.Result.Kind {
215+ return fmt.Sprintf("kind: got %s, want %s", k, c.Result.Kind)
216+ }
217+ want := stringifyValue(mustJSON(tb, string(c.Result.Value)))
218+ if got := stringifyValue(result); got != want {
219+ return fmt.Sprintf("value: got %s, want %s", got, want)
220+ }
221+ return ""
222+}
223+
224+func TestLanguageServicesFixtures(t *testing.T) {
225+ files, err := filepath.Glob("testdata/languageservices/*.json")
226+ if err != nil || len(files) != 28 {
227+ t.Fatalf("fixture files: %d, %v", len(files), err)
228+ }
229+ var total, skippedByTS, known, failed int
230+ for _, file := range files {
231+ name := strings.TrimSuffix(filepath.Base(file), ".json")
232+ data, err := os.ReadFile(file)
233+ if err != nil {
234+ t.Fatal(err)
235+ }
236+ var groups map[string][]xlangCase
237+ if err := json.Unmarshal(data, &groups); err != nil {
238+ t.Fatalf("%s: %v", file, err)
239+ }
240+ groupNames := make([]string, 0, len(groups))
241+ for g := range groups {
242+ groupNames = append(groupNames, g)
243+ }
244+ sort.Strings(groupNames)
245+ for _, g := range groupNames {
246+ for i, c := range groups[g] {
247+ total++
248+ for _, s := range c.Options.Skip {
249+ if s == "typescript" {
250+ skippedByTS++
251+ }
252+ }
253+ key := name + ": " + g
254+ label := fmt.Sprintf("%s/%s/%d", name, g, i)
255+ reason, isKnown := xlangKnownDifferences[fmt.Sprintf("%s#%d", key, i)]
256+ t.Run(label, func(t *testing.T) {
257+ msg := runXlangCase(t, c)
258+ switch {
259+ case msg == "" && isKnown:
260+ t.Errorf("listed as a known difference (%s) but passes", reason)
261+ case msg != "" && isKnown:
262+ known++
263+ t.Logf("known difference (%s): %s [%s]", reason, msg, c.Expr)
264+ case msg != "":
265+ failed++
266+ t.Errorf("%s [%s]", msg, c.Expr)
267+ }
268+ })
269+ }
270+ }
271+ }
272+ 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)
273+ if total != 1020 {
274+ t.Errorf("expected 1020 fixtures, found %d", total)
275+ }
276+}

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