Skip to content

Commit 2df4518

Browse files
authored
feat: AST-based CTE support: completion, go-to-definition, highlights, rename (#166)
## Summary Adds the classic LSP trio for statement-local SQL identifiers — go-to-definition, document highlights, and rename — and rewrites CTE completion on top of `QueryContext` (#164). All analyses are purely local; no schema required. ### CTE completion (`cteCompletionSource` rewrite) - Statement-scoped: CTEs from other statements in the doc no longer leak into completions. - Handles nested-paren bodies, 3+ comma-separated CTEs, `RECURSIVE`, and quoted names (quoted names complete with a quoted `apply`). - New: CTE **column** completions after `<cte>.` and in column positions when the statement selects `FROM` that CTE (explicit column list or select-list inference; skipped on `SELECT *`). - Regex scan remains the fallback for statements that don't parse mid-edit. Exported name/signature unchanged; `createCteCompletionSource(config)` added for sharing parser/analyzer instances. ### `findReferences` / `SqlReferenceResolver` (new) Resolves three kinds: CTE names (definition = name token in `WITH x AS`), table aliases (definition = alias token in `FROM t x`), and select-list aliases (references limited to `GROUP BY`/`ORDER BY`/`HAVING`/`QUALIFY`). Occurrence scanning runs on masked statement text, so string literals and comments are never matched. It refuses rather than guesses: ambiguous aliases (same alias on two tables), dotted qualifiers, and cursors inside strings all return null. ### Navigation extensions (new) - `sqlHighlightReferences()` — debounced cursor-driven `Decoration.mark` on all references, definition styled distinctly. - `sqlGotoDefinition()` — Mod-hover underline affordance + Mod-click jump; unresolvable Mod-clicks fall through to CodeMirror's default (multi-cursor). - `renameSqlIdentifier(view, config)` — rewrites definition + all references in one transaction (one undo step); new name comes from a `prompt` callback so hosts (marimo) can supply their own UI; refuses when not confidently resolvable — never text search-and-replace. - `sqlNavigationKeymap()` — opt-in `F12`/`Mod-b` go-to-definition, `F2` rename. Wired into `sqlExtension` behind `enableNavigation` (default on, highlights + Mod-click) and `navigationConfig` (keybindings opt-in via `keymap: true`). ### Supporting fixes (`query-context.ts`) - Quoted CTE names in the regex extractor and `findCteSpan`. - Span search on masked text so `AS (` inside a string can't fake a declaration. - `SELECT *` no longer infers `*` as a CTE output column. ### Behavior change The old test expecting CTEs from *other* statements to appear in completions was inverted — that leaking is what this PR fixes; new tests assert scoping in both directions. ## Demo The demo opens with a two-CTE query exercising every navigation kind and enables the keymap; a new `demo-doc.test.ts` guards that the demo document parses and all navigation targets resolve. Verified end-to-end in a live browser: highlights, Mod-hover underline, Mod-click jump, and F2 rename (comment/string occurrences untouched). ## Testing - 439 unit tests pass (61 new across `references.test.ts`, `navigation-extension.test.ts`, expanded `cte-completion-source.test.ts`), browser test passes, typecheck + oxlint clean, demo builds. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds AST-based, statement-scoped CTE navigation (go-to-definition, reference highlights, safe rename) and rewrites CTE completion to include CTE columns. Improves accuracy, quoted-name handling, and edit-safety — no schema required. - New Features - Rewrote CTE completion on `QueryContext`: statement-scoped, supports quoted names, nested parens, multiple CTEs, and `RECURSIVE`. Adds CTE column completion after `<cte>.` and in column positions when selected from that CTE. Regex fallback for mid-edit. New `createCteCompletionSource({ parser })`. - Added `findReferences` / `SqlReferenceResolver`: resolves CTE names, table aliases, and select aliases; scans masked text (ignores strings/comments); refuses ambiguous cases. - Added navigation: `sqlHighlightReferences` (cursor-driven highlights), `sqlGotoDefinition` (Mod-hover underline + Mod-click jump), `renameSqlIdentifier` (single-transaction rename via host `prompt`), and `sqlNavigationKeymap` (F12/Mod-b/F2). Wired into `sqlExtension` via `enableNavigation` (default on) and `navigationConfig.keymap`. - Exported new APIs: `createCteCompletionSource`, `findReferences`, `gotoSqlDefinition`, `renameSqlIdentifier`, `sqlNavigation`, `sqlNavigationKeymap`, and related types. Updated README, demo, and tests. - Bug Fixes - CTE references limited to relation positions and qualifiers; bare same-named columns are not matched. Refuse collisions with select aliases; alias definition search anchored to relation positions; resolves quoted identifiers under the cursor. - Navigation drops stale results if the doc changes while resolving; hover state resets on edits; rename rejects reserved words and cancels if the doc changed during prompting. - Completion quotes non-bare CTE column labels on apply and treats `$` as part of identifier tokens. - `sqlExtension` navigation inherits `linterConfig.parser`/`structureAnalyzer` by default for consistent dialect behavior. <sup>Written for commit 5bb4820. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/marimo-team/codemirror-sql/pull/166?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent 2ef198c commit 2df4518

16 files changed

Lines changed: 1736 additions & 95 deletions

README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ A CodeMirror extension for SQL linting and visual gutter indicators. Built by an
88
- 🧠 **Schema-aware linting** - Warns about unknown tables, unknown columns, and ambiguous column references based on your schema
99
- 🎨 **Visual gutter** - Color-coded statement indicators and error highlighting
1010
- 💡 **Hover tooltips** - Schema info, keywords, and column details on hover
11-
- 🔮 **CTE autocomplete** - Auto-complete support for CTEs
11+
- 🔮 **CTE autocomplete** - Statement-scoped completion of CTE names and their output columns
1212
- 🏷️ **Alias resolution** - Hover and completion understand table aliases (`SELECT u.name FROM users u`)
13+
- 🧭 **Navigation** - Go-to-definition, reference highlighting, and rename for CTEs and aliases
1314
- 🎯 **Query-aware resolution** - Context-sensitive schema and column suggestions
1415
- 🔍 **Additional dialects** - DuckDB, BigQuery, Dremio
1516
- 🛠️ **Custom renderers** - Customizable tooltip rendering for tables, columns, and keywords
@@ -110,6 +111,36 @@ diagnostics carry `source: "sql-schema"`; syntax diagnostics use
110111
`source: "sql-parser"`. If the schema is provided as a function, it is called
111112
on every lint pass and should be cheap/memoized.
112113

114+
### Navigation: go-to-definition, highlights, rename
115+
116+
`sqlExtension` includes navigation for statement-local identifiers (CTE names,
117+
table aliases, select aliases) by default: the references of the identifier
118+
under the cursor are highlighted, and Mod-click (Cmd/Ctrl-click) on a
119+
resolvable identifier jumps to its definition. Keybindings (`F12`/`Mod-b` for
120+
go-to-definition, `F2` for rename) are opt-in:
121+
122+
```ts
123+
import { renameSqlIdentifier, sqlExtension } from "@marimo-team/codemirror-sql";
124+
125+
sqlExtension({
126+
enableNavigation: true, // default
127+
navigationConfig: {
128+
keymap: true, // enable F12 / Mod-b / F2
129+
// Supply your own rename UI (defaults to window.prompt)
130+
prompt: (currentName) => window.prompt(`Rename '${currentName}' to:`, currentName),
131+
},
132+
});
133+
134+
// Rename programmatically: rewrites the definition and all references
135+
// in a single undo step
136+
await renameSqlIdentifier(view, { prompt: () => "new_name" });
137+
```
138+
139+
The pieces are also exported individually: `sqlHighlightReferences`,
140+
`sqlGotoDefinition`, `sqlNavigationKeymap`, `gotoSqlDefinition`, and
141+
`findReferences`. Rename returns `false` when the identifier can't be
142+
confidently resolved — it never falls back to text search-and-replace.
143+
113144
## Additional Dialects
114145

115146
This extension adds support for additional dialects:

demo/data.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,20 @@ import type { Schema } from "./custom-renderers";
44
export const defaultSqlDoc = `-- Welcome to the SQL Editor Demo!
55
-- Try editing the queries below to see real-time validation
66
7-
WITH cte_name AS (
8-
SELECT * FROM users
7+
-- Put the cursor on a CTE name or alias to highlight its references;
8+
-- Cmd/Ctrl-click (or F12) jumps to the definition, F2 renames.
9+
WITH recent_orders AS (
10+
SELECT customer_id, total_amount FROM orders WHERE order_date >= '2024-01-01'
11+
),
12+
top_customers AS (
13+
SELECT customer_id, SUM(total_amount) AS total_spent
14+
FROM recent_orders
15+
GROUP BY customer_id
916
)
17+
SELECT c.first_name, t.total_spent AS amount
18+
FROM customers c
19+
JOIN top_customers t ON t.customer_id = c.id
20+
ORDER BY amount DESC;
1021
1122
-- Valid queries (no errors):
1223
SELECT id, name, email

demo/index.html

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,15 @@ <h1 class="text-4xl font-bold mb-4">
2828
<div class="grid gap-8">
2929
<section>
3030
<h2 class="text-2xl font-bold mb-4">SQL Editor with Diagnostics</h2>
31-
<p class="mb-4 text-gray-600">
31+
<p class="mb-2 text-gray-600">
3232
Try typing invalid SQL syntax to see real-time error highlighting and messages.
3333
Valid tables are: <span class="font-bold">users, posts, orders, customers, categories</span>
3434
</p>
35+
<p class="mb-4 text-gray-600">
36+
Put the cursor on a CTE name or alias to highlight its references,
37+
<span class="font-bold">Cmd/Ctrl-click</span> (or <span class="font-bold">F12</span>) to jump to its
38+
definition, and press <span class="font-bold">F2</span> to rename it everywhere in the statement.
39+
</p>
3540

3641
<div class="flex justify-end items-center mb-2">
3742
<select id="database-select" class="border rounded-md p-1.5">
@@ -64,6 +69,9 @@ <h3 class="font-semibold text-lg">Valid Queries</h3>
6469
<button class="example-btn w-full p-3 text-left bg-green-50 hover:bg-green-100 border border-green-200 rounded">
6570
<code class="text-sm">INSERT INTO products (name, price) VALUES ('Laptop', 999.99)</code>
6671
</button>
72+
<button class="example-btn w-full p-3 text-left bg-green-50 hover:bg-green-100 border border-green-200 rounded">
73+
<code class="text-sm">WITH active_users AS (SELECT id, name FROM users WHERE active = true) SELECT a.name FROM active_users a ORDER BY a.name</code>
74+
</button>
6775
<button class="example-btn w-full p-3 text-left bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded">
6876
<code class="text-sm">from nyc.rideshare select * limit 100</code>
6977
</button>
@@ -102,6 +110,20 @@ <h3 class="font-semibold mb-2">Multiple SQL Dialects</h3>
102110
<h3 class="font-semibold mb-2">DuckDB Support</h3>
103111
<p class="text-sm text-gray-700">Special support for DuckDB-specific syntax like "from table select * limit 100".</p>
104112
</div>
113+
<div class="p-4 bg-blue-50 rounded-lg">
114+
<h3 class="font-semibold mb-2">CTE &amp; Alias Navigation</h3>
115+
<p class="text-sm text-gray-700">
116+
Go-to-definition (Cmd/Ctrl-click or F12), reference highlighting, and rename (F2) for CTE names,
117+
table aliases, and select aliases.
118+
</p>
119+
</div>
120+
<div class="p-4 bg-blue-50 rounded-lg">
121+
<h3 class="font-semibold mb-2">CTE Autocomplete</h3>
122+
<p class="text-sm text-gray-700">
123+
Statement-scoped completion of CTE names and their output columns, including after
124+
<code>my_cte.</code>.
125+
</p>
126+
</div>
105127
<div class="p-4 bg-blue-50 rounded-lg">
106128
<h3 class="font-semibold mb-2">TypeScript Support</h3>
107129
<p class="text-sm text-gray-700">Full TypeScript support with comprehensive type definitions.</p>

demo/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { keymap } from "@codemirror/view";
55
import { basicSetup, EditorView } from "codemirror";
66
import {
77
aliasColumnCompletionSource,
8-
cteCompletionSource,
8+
createCteCompletionSource,
99
DefaultSqlTooltipRenders,
1010
defaultSqlHoverTheme,
1111
NodeSqlParser,
@@ -174,9 +174,16 @@ function initializeEditor() {
174174
theme: defaultSqlHoverTheme("light"),
175175
parser,
176176
},
177+
// Reference highlighting and go-to-definition for CTEs/aliases
178+
enableNavigation: true,
179+
navigationConfig: {
180+
keymap: true, // F12/Mod-b go-to-definition, F2 rename
181+
parser,
182+
},
177183
}),
178184
defaultDialect.language.data.of({
179-
autocomplete: cteCompletionSource,
185+
// Statement-scoped CTE names and their output columns
186+
autocomplete: createCteCompletionSource({ parser }),
180187
}),
181188
defaultDialect.language.data.of({
182189
// Complete `u.` -> columns of `users` in `SELECT ... FROM users u`

src/__tests__/index.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,24 @@ describe("index.ts exports", () => {
1212
"DefaultSqlTooltipRenders",
1313
"NodeSqlParser",
1414
"QueryContextAnalyzer",
15+
"SqlReferenceResolver",
1516
"SqlStructureAnalyzer",
1617
"aliasColumnCompletionSource",
1718
"analyzeQueryContext",
19+
"createCteCompletionSource",
1820
"cteCompletionSource",
1921
"defaultSqlHoverTheme",
22+
"findReferences",
23+
"gotoSqlDefinition",
24+
"renameSqlIdentifier",
2025
"resolveSqlSchema",
2126
"sqlExtension",
27+
"sqlGotoDefinition",
28+
"sqlHighlightReferences",
2229
"sqlHover",
2330
"sqlLinter",
31+
"sqlNavigation",
32+
"sqlNavigationKeymap",
2433
"sqlSchemaFacet",
2534
"sqlSemanticLinter",
2635
"sqlStructureGutter",

src/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ export {
22
type AliasCompletionConfig,
33
aliasColumnCompletionSource,
44
} from "./sql/alias-completion-source.js";
5-
export { cteCompletionSource } from "./sql/cte-completion-source.js";
5+
export {
6+
createCteCompletionSource,
7+
type CteCompletionConfig,
8+
cteCompletionSource,
9+
} from "./sql/cte-completion-source.js";
610
export { type SqlLinterConfig, sqlLinter } from "./sql/diagnostics.js";
711
export { type SqlExtensionConfig, sqlExtension } from "./sql/extension.js";
812
export type {
@@ -12,6 +16,15 @@ export type {
1216
SqlKeywordInfo,
1317
} from "./sql/hover.js";
1418
export { DefaultSqlTooltipRenders, defaultSqlHoverTheme, sqlHover } from "./sql/hover.js";
19+
export {
20+
gotoSqlDefinition,
21+
renameSqlIdentifier,
22+
type SqlNavigationConfig,
23+
sqlGotoDefinition,
24+
sqlHighlightReferences,
25+
sqlNavigation,
26+
sqlNavigationKeymap,
27+
} from "./sql/navigation-extension.js";
1528
export {
1629
NodeSqlParser,
1730
type NodeSqlParserOptions,
@@ -26,6 +39,14 @@ export {
2639
type QueryContextCte,
2740
type QueryContextTable,
2841
} from "./sql/query-context.js";
42+
export {
43+
findReferences,
44+
type SqlIdentifierKind,
45+
type SqlRange,
46+
type SqlReferenceConfig,
47+
SqlReferenceResolver,
48+
type SqlReferenceResult,
49+
} from "./sql/references.js";
2950
export { resolveSqlSchema, type SqlSchemaSource, sqlSchemaFacet } from "./sql/schema-facet.js";
3051
export {
3152
type SemanticSeverity,

src/sql/__tests__/cte-completion-source.test.ts

Lines changed: 141 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ function createMockContext(doc: string, pos: number, explicit = false): Completi
2323
matchBefore: (pattern: RegExp) => {
2424
const before = doc.slice(0, pos);
2525

26-
// For \w* pattern, find the word at the end
27-
if (pattern.source === "\\w*") {
28-
const wordMatch = before.match(/(\w*)$/);
26+
// For word patterns, find the word at the end
27+
if (pattern.source === "\\w*" || pattern.source === "[\\w$]*") {
28+
const wordMatch = before.match(/([\w$]*)$/);
2929
if (!wordMatch) return null;
3030

3131
const text = wordMatch[1] || "";
@@ -182,7 +182,7 @@ describe("cteCompletionSource", () => {
182182
expect(result?.options[0].label).toBe("filtered_users");
183183
});
184184

185-
it("should handle multiple WITH clauses in different statements", async () => {
185+
it("scopes CTEs to the statement containing the cursor", async () => {
186186
const sql = `WITH first_cte AS (SELECT 1)
187187
SELECT * FROM first_cte;
188188
@@ -193,10 +193,22 @@ describe("cteCompletionSource", () => {
193193
const result = await getCompletionResult(context);
194194

195195
expect(result).toBeTruthy();
196-
expect(result?.options).toHaveLength(2);
196+
// first_cte belongs to the previous statement and must not leak in
197+
expect(result?.options.map((opt) => opt.label)).toEqual(["second_cte"]);
198+
});
197199

198-
const labels = result?.options.map((opt) => opt.label).sort();
199-
expect(labels).toEqual(["first_cte", "second_cte"]);
200+
it("offers the first statement's CTE when the cursor is in it", async () => {
201+
const sql = `WITH first_cte AS (SELECT 1)
202+
SELECT * FROM first_;
203+
204+
WITH second_cte AS (SELECT 2)
205+
SELECT * FROM second_cte`;
206+
207+
const pos = sql.indexOf("first_;") + "first_".length;
208+
const context = createMockContext(sql, pos, true);
209+
const result = await getCompletionResult(context);
210+
211+
expect(result?.options.map((opt) => opt.label)).toEqual(["first_cte"]);
200212
});
201213
});
202214

@@ -316,5 +328,127 @@ describe("cteCompletionSource", () => {
316328
expect(labels).toContain("a");
317329
expect(labels).toContain("b");
318330
});
331+
332+
it("detects 3+ comma-separated CTEs with nested parens mid-edit", async () => {
333+
const sql =
334+
"WITH a AS (SELECT max(x) FROM (SELECT 1) s), b AS (SELECT count(*) FROM t), c AS (SELECT 3) SELECT * FROM ";
335+
336+
const context = createMockContext(sql, sql.length, true);
337+
const result = await getCompletionResult(context);
338+
339+
const labels = result?.options.map((option) => option.label) ?? [];
340+
expect(labels).toEqual(["a", "b", "c"]);
341+
});
342+
});
343+
344+
describe("quoted CTE names", () => {
345+
it("detects quoted CTE names", async () => {
346+
const sql = `WITH "My Stats" AS (SELECT 1) SELECT * FROM `;
347+
348+
const context = createMockContext(sql, sql.length, true);
349+
const result = await getCompletionResult(context);
350+
351+
expect(result?.options.map((opt) => opt.label)).toEqual(["My Stats"]);
352+
expect(result?.options[0].apply).toBe('"My Stats"');
353+
});
354+
355+
it("does not add an apply override for bare names", async () => {
356+
const sql = "WITH stats AS (SELECT 1) SELECT * FROM ";
357+
358+
const context = createMockContext(sql, sql.length, true);
359+
const result = await getCompletionResult(context);
360+
361+
expect(result?.options[0].apply).toBeUndefined();
362+
});
363+
364+
it("completes names containing $ as one token", async () => {
365+
const sql = "WITH t$1 AS (SELECT 1) SELECT * FROM t$";
366+
367+
const context = createMockContext(sql, sql.length, true);
368+
const result = await getCompletionResult(context);
369+
370+
expect(result?.from).toBe(sql.lastIndexOf("t$"));
371+
expect(result?.options[0].label).toBe("t$1");
372+
});
373+
374+
it("quotes non-bare column labels on apply", async () => {
375+
const sql = 'WITH t("My Col", b) AS (SELECT 1, 2) SELECT t. FROM t';
376+
const pos = sql.indexOf("t. FROM") + 2;
377+
378+
const context = createMockContext(sql, pos, false);
379+
const result = await getCompletionResult(context);
380+
381+
const myCol = result?.options.find((opt) => opt.label === "My Col");
382+
expect(myCol?.apply).toBe('"My Col"');
383+
expect(result?.options.find((opt) => opt.label === "b")?.apply).toBeUndefined();
384+
});
385+
});
386+
387+
describe("CTE column completion", () => {
388+
it("completes columns after <cte>. from a declared column list", async () => {
389+
const sql = "WITH t(a, b) AS (SELECT 1, 2) SELECT t. FROM t";
390+
const pos = sql.indexOf("t. FROM") + 2;
391+
392+
const context = createMockContext(sql, pos, false);
393+
const result = await getCompletionResult(context);
394+
395+
expect(result).toBeTruthy();
396+
expect(result?.from).toBe(pos);
397+
expect(result?.options.map((opt) => opt.label)).toEqual(["a", "b"]);
398+
expect(result?.options[0].type).toBe("property");
399+
expect(result?.options[0].detail).toBe("column of t");
400+
});
401+
402+
it("completes columns after <cte>. inferred from the select list", async () => {
403+
const sql = "WITH t AS (SELECT id, name AS n FROM users) SELECT t. FROM t";
404+
const pos = sql.indexOf("t. FROM") + 2;
405+
406+
const context = createMockContext(sql, pos, false);
407+
const result = await getCompletionResult(context);
408+
409+
expect(result?.options.map((opt) => opt.label)).toEqual(["id", "n"]);
410+
});
411+
412+
it("offers no columns for a SELECT * CTE", async () => {
413+
const sql = "WITH t AS (SELECT * FROM users) SELECT t. FROM t";
414+
const pos = sql.indexOf("t. FROM") + 2;
415+
416+
const context = createMockContext(sql, pos, false);
417+
const result = await getCompletionResult(context);
418+
419+
expect(result).toBeNull();
420+
});
421+
422+
it("does not complete columns for a multi-segment qualifier", async () => {
423+
const sql = "WITH t(a) AS (SELECT 1) SELECT db.t. FROM t";
424+
const pos = sql.indexOf("db.t. FROM") + "db.t.".length;
425+
426+
const context = createMockContext(sql, pos, false);
427+
const result = await getCompletionResult(context);
428+
429+
expect(result).toBeNull();
430+
});
431+
432+
it("offers CTE columns unqualified in a column position", async () => {
433+
const sql = "WITH t(a, b) AS (SELECT 1, 2) SELECT FROM t";
434+
const pos = sql.indexOf("SELECT FROM") + "SELECT ".length;
435+
436+
const context = createMockContext(sql, pos, true);
437+
const result = await getCompletionResult(context);
438+
439+
const labels = result?.options.map((opt) => opt.label) ?? [];
440+
expect(labels).toContain("t");
441+
expect(labels).toContain("a");
442+
expect(labels).toContain("b");
443+
});
444+
445+
it("does not offer columns right after FROM", async () => {
446+
const sql = "WITH t(a, b) AS (SELECT 1, 2) SELECT a FROM ";
447+
448+
const context = createMockContext(sql, sql.length, true);
449+
const result = await getCompletionResult(context);
450+
451+
expect(result?.options.map((opt) => opt.label)).toEqual(["t"]);
452+
});
319453
});
320454
});

0 commit comments

Comments
 (0)