diff --git a/docs/HIGHLIGHTING.md b/docs/HIGHLIGHTING.md index 953c3ceb..a64de130 100644 --- a/docs/HIGHLIGHTING.md +++ b/docs/HIGHLIGHTING.md @@ -352,6 +352,20 @@ The result has the shape `{"spans":[{"start":0,"end":6,"category":"keyword"}]}`. The [gosqlx.dev playground](https://gosqlx.dev/playground/) uses it for editor highlighting. +### Dialect arguments + +The WASM bridge accepts dialect names for these calls: + +| Call | Unknown dialect | Empty or omitted dialect | +|------|-----------------|--------------------------| +| `gosqlxParse`, `gosqlxFormat`, `gosqlxLint`, `gosqlxAnalyze`, `gosqlxHighlight` | Generic rules | Generic rules | +| `gosqlxTranspile` | Error response for an unsupported source or target dialect | Empty means generic; both arguments are required | + +Transpile validates both dialects because silently choosing another dialect +could produce misleading SQL. Its raw JSON response includes an `error` such +as `unsupported source dialect "nope"`; the playground wrapper throws that +error message. The other calls retain their lenient fallback. + ## Performance Highlighting is one tokenizer pass plus classification. `BenchmarkSpans` (about diff --git a/wasm/internal/bridge/bridge.go b/wasm/internal/bridge/bridge.go index f400aa0d..94f5841e 100644 --- a/wasm/internal/bridge/bridge.go +++ b/wasm/internal/bridge/bridge.go @@ -59,24 +59,16 @@ func Parse(sql, dialect string) (*ast.AST, error) { // TextResponse is the result of Format and Transpile: the rewritten SQL, or // the reason there is none. // -// Exactly one of Result and Error is set. ErrorSpan locates Error in the -// submitted SQL when the error carries a position (a tokenizer or parser -// error); errors such as formatter.ErrUnplaceableComment, an unsupported -// dialect or a construct the transpile target cannot express have none. -// -// ErrorSpan is part of the JSON contract for callers that want it. The -// website's WasmLoader.parseResult treats a non-empty "error" like every other -// export does and throws it as an exception, so the playground's Format and -// Transpile tabs show only the error message, not the span. +// Exactly one of Result and Error is set. Parse-error positions are reported +// separately by Lint and Analyze through their ParseErrorSpan fields. type TextResponse struct { - Result string `json:"result"` - Error string `json:"error,omitempty"` - ErrorSpan *ErrorSpan `json:"error_span,omitempty"` + Result string `json:"result"` + Error string `json:"error,omitempty"` } -func textResponse(sql, result string, err error) TextResponse { +func textResponse(result string, err error) TextResponse { if err != nil { - return TextResponse{Error: err.Error(), ErrorSpan: errorSpan(sql, err)} + return TextResponse{Error: err.Error()} } return TextResponse{Result: result} } @@ -88,7 +80,7 @@ func textResponse(sql, result string, err error) TextResponse { // faithfully is refused with an error wrapping formatter.ErrUnplaceableComment. func Format(sql, dialect string) TextResponse { out, err := gosqlx.FormatWithDialect(sql, gosqlx.DefaultFormatOptions(), Dialect(dialect)) - return textResponse(sql, out, err) + return textResponse(out, err) } // transpileDialect resolves a transpile source or target. Unlike Dialect, an @@ -110,14 +102,14 @@ func transpileDialect(role, name string) (sqlkeywords.SQLDialect, error) { func Transpile(sql, from, to string) TextResponse { src, err := transpileDialect("source", from) if err != nil { - return textResponse(sql, "", err) + return textResponse("", err) } dst, err := transpileDialect("target", to) if err != nil { - return textResponse(sql, "", err) + return textResponse("", err) } out, err := gosqlx.Transpile(sql, src, dst) - return textResponse(sql, out, err) + return textResponse(out, err) } // LintViolation is one lint finding as the playground renders it. diff --git a/wasm/internal/bridge/bridge_test.go b/wasm/internal/bridge/bridge_test.go index a3f2eb50..4e06537f 100644 --- a/wasm/internal/bridge/bridge_test.go +++ b/wasm/internal/bridge/bridge_test.go @@ -235,6 +235,7 @@ func TestFormat(t *testing.T) { {name: "sqlserver keeps TOP", sql: "SELECT TOP 10 * FROM t", dialect: "sqlserver", want: "SELECT TOP 10 *\nFROM t"}, {name: "leading comment kept", sql: "-- c\nSELECT 1", dialect: "generic", want: "-- c\nSELECT 1"}, {name: "inline comment moves above the statement", sql: "-- top\nSELECT /* inline */ a FROM t", dialect: "sqlserver", want: "-- top\n/* inline */\nSELECT a\nFROM t"}, + {name: "unknown dialect is generic", sql: "SELECT 1", dialect: "nope", want: "SELECT 1"}, {name: "empty dialect is generic", sql: "select id,name from users where id>5", dialect: "", want: "SELECT id, name\nFROM users\nWHERE id > 5"}, } for _, tt := range tests { @@ -257,9 +258,6 @@ func TestFormat_UsesDialect(t *testing.T) { if resp.Error == "" { t.Fatalf("generic should reject TOP; got %q", resp.Result) } - if resp.ErrorSpan == nil || resp.ErrorSpan.Line != 1 { - t.Fatalf("parse error should carry a span, got %+v", resp.ErrorSpan) - } } func TestFormat_UnplaceableComment(t *testing.T) { @@ -268,9 +266,6 @@ func TestFormat_UnplaceableComment(t *testing.T) { if resp.Result != "" || !strings.Contains(resp.Error, formatter.ErrUnplaceableComment.Error()) { t.Fatalf("expected an unplaceable-comment error, got %+v", resp) } - if resp.ErrorSpan != nil { - t.Fatalf("a comment error has no parser position, got span %+v", resp.ErrorSpan) - } } func TestTranspile(t *testing.T) { @@ -304,10 +299,9 @@ func TestTranspile_Errors(t *testing.T) { sql string from, to string contains string - span bool }{ {name: "xor has no oracle form", sql: "SELECT a ^ b FROM t", from: "mysql", to: "oracle", contains: "no equivalent in oracle"}, - {name: "source parse error", sql: invalidSQL, from: "mysql", to: "postgresql", contains: "E2002", span: true}, + {name: "source parse error", sql: invalidSQL, from: "mysql", to: "postgresql", contains: "E2002"}, {name: "unknown source", sql: "SELECT 1", from: "nope", to: "postgresql", contains: `source dialect "nope"`}, {name: "unknown target", sql: "SELECT 1", from: "mysql", to: "nope", contains: `target dialect "nope"`}, } @@ -317,9 +311,6 @@ func TestTranspile_Errors(t *testing.T) { if resp.Result != "" || !strings.Contains(resp.Error, tt.contains) { t.Fatalf("expected an error containing %q, got %+v", tt.contains, resp) } - if (resp.ErrorSpan != nil) != tt.span { - t.Fatalf("span = %+v, want present=%v", resp.ErrorSpan, tt.span) - } }) } } @@ -332,17 +323,30 @@ func TestTextResponse_JSONContract(t *testing.T) { if string(ok) != `{"result":"SELECT 1"}` { t.Fatalf("success payload = %s", ok) } - bad, err := json.Marshal(Format(invalidSQL, "generic")) - if err != nil { - t.Fatalf("marshal: %v", err) - } - var raw map[string]any - if err := json.Unmarshal(bad, &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - for _, key := range []string{"result", "error", "error_span"} { - if _, ok := raw[key]; !ok { - t.Errorf("error payload missing %q: %s", key, bad) - } + for _, tt := range []struct { + name string + resp TextResponse + }{ + {"format", Format(invalidSQL, "generic")}, + {"transpile", Transpile(invalidSQL, "mysql", "postgresql")}, + } { + t.Run(tt.name, func(t *testing.T) { + bad, err := json.Marshal(tt.resp) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(bad, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"result", "error"} { + if _, ok := raw[key]; !ok { + t.Errorf("error payload missing %q: %s", key, bad) + } + } + if _, ok := raw["error_span"]; ok { + t.Errorf("error payload contains unused error_span: %s", bad) + } + }) } } diff --git a/wasm/internal/bridge/contract_test.go b/wasm/internal/bridge/contract_test.go index 75899d7e..28a28fe4 100644 --- a/wasm/internal/bridge/contract_test.go +++ b/wasm/internal/bridge/contract_test.go @@ -69,8 +69,8 @@ func contractSample() any { Highlight: HighlightResponse{ Spans: []HighlightSpan{{Start: 0, End: 6, Category: "keyword"}}, }, - Format: TextResponse{Result: "SELECT 1", Error: "format error", ErrorSpan: sampleSpan}, - Transpile: TextResponse{Result: "SELECT 1", Error: "transpile error", ErrorSpan: sampleSpan}, + Format: TextResponse{Result: "SELECT 1", Error: "format error"}, + Transpile: TextResponse{Result: "SELECT 1", Error: "transpile error"}, } } diff --git a/wasm/main.go b/wasm/main.go index 0a87539c..e406933b 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -49,8 +49,9 @@ func parse(_ js.Value, args []js.Value) any { } // format formats the SQL for the selected dialect, keeping comments and -// optimizer hints. It returns {"result": "..."} or {"error": "...", -// "error_span": {...}} (bridge.TextResponse). +// optimizer hints. Unknown or omitted dialects use generic rules, as do parse, +// lint, analyze and highlight. It returns {"result": "..."} on success or +// {"result": "", "error": "..."} on failure (bridge.TextResponse). func format(_ js.Value, args []js.Value) any { if len(args) == 0 { return jsonError("no SQL provided") @@ -59,7 +60,9 @@ func format(_ js.Value, args []js.Value) any { } // transpile rewrites the SQL from the dialect in the second argument to the -// one in the third. It returns the same envelope as format. +// one in the third. It returns the same envelope as format. Unlike the other +// dialect-aware exports, an unknown source or target dialect returns an error +// containing unsupported source|target dialect "x". An empty string means generic. func transpile(_ js.Value, args []js.Value) any { if len(args) < 3 { return jsonError("transpile needs SQL, a source dialect and a target dialect") diff --git a/website/package.json b/website/package.json index 5303bdb1..083f5e7a 100644 --- a/website/package.json +++ b/website/package.json @@ -12,7 +12,7 @@ "wasm": "bash scripts/build-wasm.sh", "typecheck": "tsc --noEmit", "lint": "eslint .", - "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/changelog.test.mjs scripts/editorDiagnostics.test.mjs scripts/editorHighlight.test.mjs scripts/bench-format.test.mjs", + "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/changelog.test.mjs scripts/editorDiagnostics.test.mjs scripts/editorHighlight.test.mjs scripts/bench-format.test.mjs scripts/sqlOutput.test.mjs scripts/copyFeedback.test.mjs", "check:metrics": "node scripts/check-metrics-drift.mjs", "check:docs": "node scripts/check-docs-content.mjs --self-test && node scripts/check-docs-content.mjs", "check:anchors": "node scripts/check-dialect-anchors.mjs", diff --git a/website/scripts/copyFeedback.test.mjs b/website/scripts/copyFeedback.test.mjs new file mode 100644 index 00000000..40701ea5 --- /dev/null +++ b/website/scripts/copyFeedback.test.mjs @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCopyFeedback } from '../src/components/playground/useCopyFeedback.ts'; + +test('copy success and failure reset to idle after the feedback duration', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const states = []; + const feedback = createCopyFeedback((state) => states.push(state), 2000); + for (const state of ['copied', 'failed']) { + feedback.flash(state); + assert.equal(states.at(-1), state); + t.mock.timers.tick(1999); + assert.equal(states.at(-1), state); + t.mock.timers.tick(1); + assert.equal(states.at(-1), 'idle'); + } +}); + +test('a second flash replaces feedback and restarts its reset timer', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const states = []; + const feedback = createCopyFeedback((state) => states.push(state), 2000); + feedback.flash('copied'); + t.mock.timers.tick(1500); + feedback.flash('failed'); + t.mock.timers.tick(500); + assert.deepEqual(states, ['copied', 'failed']); + t.mock.timers.tick(1500); + assert.deepEqual(states, ['copied', 'failed', 'idle']); + t.mock.timers.runAll(); + assert.equal(states.length, 3); +}); + +test('dispose on unmount cancels the reset and ignores late clipboard completion', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const states = []; + const feedback = createCopyFeedback((state) => states.push(state), 2000); + feedback.flash('copied'); + feedback.dispose(); + feedback.dispose(); + feedback.flash('failed'); + t.mock.timers.runAll(); + assert.deepEqual(states, ['copied']); +}); diff --git a/website/scripts/sqlOutput.test.mjs b/website/scripts/sqlOutput.test.mjs new file mode 100644 index 00000000..a3139b5d --- /dev/null +++ b/website/scripts/sqlOutput.test.mjs @@ -0,0 +1,106 @@ +import { after, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { registerHooks } from 'node:module'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import ts from 'typescript'; + +const components = new URL('../src/components/playground/', import.meta.url).href; +// Transpile the real component graph in process. Only the CodeMirror boundary +// is replaced: server rendering needs no DOM or editor implementation. +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (context.parentURL?.startsWith(components) && specifier.startsWith('./')) { + const url = new URL(specifier, context.parentURL); + for (const extension of ['.tsx', '.ts']) { + if (existsSync(new URL(url.href + extension))) { + return nextResolve(url.href + extension, context); + } + } + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (!url.startsWith(components) || !/\.tsx?$/.test(url)) { + return nextLoad(url, context); + } + if (url.endsWith('/SqlEditor.tsx')) { + return { + format: 'module', shortCircuit: true, + source: `import { createElement } from 'react'; + export default function SqlEditor({ value, readOnly }) { + return createElement('pre', { 'data-editor-stub': true, 'data-read-only': readOnly }, value); + }`, + }; + } + const { outputText } = ts.transpileModule(readFileSync(new URL(url), 'utf8'), { + fileName: new URL(url).pathname, + compilerOptions: { + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }, + }); + return { format: 'module', shortCircuit: true, source: outputText }; + }, +}); +after(() => hooks.deregister()); + +const { SqlOutput } = await import('../src/components/playground/FormatTab.tsx'); +const { default: TranspileTab } = await import('../src/components/playground/TranspileTab.tsx'); +const renderOutput = (data) => renderToStaticMarkup(createElement(SqlOutput, { + data, label: 'Formatted SQL', errorTitle: 'Format Error', emptyText: 'Waiting for SQL', +})); +const renderTranspile = (data) => renderToStaticMarkup(createElement(TranspileTab, { + data, + dialects: [{ value: 'generic', label: 'Generic' }, { value: 'postgresql', label: 'PostgreSQL' }], + from: 'generic', to: 'postgresql', onFromChange() {}, onToChange() {}, +})); + +for (const [name, render, title, copyLabel, emptyText] of [ + ['SqlOutput', renderOutput, 'Format Error', 'formatted sql', 'Waiting for SQL'], + ['TranspileTab', renderTranspile, 'Transpile Error', 'transpiled sql', 'No data available. Enter a SQL query to transpile it.'], +]) { + test(`${name} renders a polite error with hint and help, without SQL output controls`, () => { + const markup = render({ error: 'boom Hint: fix it Help: https://x.dev/e', result: '' }); + assert.ok(markup.includes(title)); + assert.match(markup, /role="status"/); + assert.match(markup, /aria-live="polite"/); + assert.match(markup, />boom<\/p>/); + assert.match(markup, /Hint: /); + assert.match(markup, /fix it/); + assert.match(markup, /href="https:\/\/x.dev\/e"/); + assert.ok(!markup.includes(`Copy ${copyLabel}`)); + assert.ok(!markup.includes('read-only')); + assert.ok(!markup.includes('data-editor-stub')); + if (name === 'TranspileTab') { + assert.match(markup, /