diff --git a/.github/workflows/check_updates.generated.yml b/.github/workflows/check_updates.generated.yml deleted file mode 100644 index 1c77b9e..0000000 --- a/.github/workflows/check_updates.generated.yml +++ /dev/null @@ -1,32 +0,0 @@ -# GENERATED BY ./check_updates.ts -- DO NOT DIRECTLY EDIT - -name: check_updates -on: - workflow_dispatch: {} - schedule: - - cron: 0 7 * * * -jobs: - build: - name: check updates - if: github.repository == 'dprint/dprint-plugin-sql' - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Clone repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: '${{ secrets.GH_DPRINTBOT_PAT }}' - - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2 - - uses: dsherret/rust-toolchain-file@3551321aa44dd44a0393eb3b6bdfbc5d25ecf621 # v1 - - name: Run script - run: |- - git config user.email "dprintbot@users.noreply.github.com" - git config user.name "dprintbot" - deno run -A ./scripts/update.ts - workflow-keepalive: - if: github.event_name == 'schedule' - runs-on: ubuntu-latest - permissions: - actions: write - steps: - - uses: liskin/gh-workflow-keepalive@f72ff1a1336129f29bf0166c0fd0ca6cf1bcb38c diff --git a/.github/workflows/check_updates.ts b/.github/workflows/check_updates.ts deleted file mode 100755 index 2a8997a..0000000 --- a/.github/workflows/check_updates.ts +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env -S deno run -A -import { job, workflow } from "jsr:@david/gagen@^0.5.0"; - -const buildJob = job("build", { - name: "check updates", - if: "github.repository == 'dprint/dprint-plugin-sql'", - runsOn: "ubuntu-latest", - timeoutMinutes: 45, - steps: [ - { - name: "Clone repository", - uses: "actions/checkout@v6", - with: { token: "${{ secrets.GH_DPRINTBOT_PAT }}" }, - }, - { uses: "denoland/setup-deno@v2" }, - { uses: "dsherret/rust-toolchain-file@v1" }, - { - name: "Run script", - run: [ - `git config user.email "dprintbot@users.noreply.github.com"`, - `git config user.name "dprintbot"`, - "deno run -A ./scripts/update.ts", - ], - }, - ], -}); - -// This is necessary to prevent GH automatically disabling this workflow after 60 days. -const keepaliveJob = job("workflow-keepalive", { - if: "github.event_name == 'schedule'", - runsOn: "ubuntu-latest", - permissions: { actions: "write" }, - steps: [ - { uses: "liskin/gh-workflow-keepalive@f72ff1a1336129f29bf0166c0fd0ca6cf1bcb38c" }, - ], -}); - -workflow({ - name: "check_updates", - on: { - workflow_dispatch: {}, - schedule: [ - // do this once per day - { cron: "0 7 * * *" }, - ], - }, - jobs: [buildJob, keepaliveJob], -}).writeOrLint({ - filePath: new URL("./check_updates.generated.yml", import.meta.url), - header: "# GENERATED BY ./check_updates.ts -- DO NOT DIRECTLY EDIT", - pinDeps: true, -}); diff --git a/.github/workflows/ci.generated.yml b/.github/workflows/ci.generated.yml index 0977c19..b90417a 100644 --- a/.github/workflows/ci.generated.yml +++ b/.github/workflows/ci.generated.yml @@ -45,6 +45,15 @@ jobs: - name: Test debug if: matrix.config.kind == 'test_debug' run: cargo test + - name: Test tracing + if: matrix.config.kind == 'test_debug' + run: cargo test --features tracing + - name: Clippy + if: matrix.config.kind == 'test_debug' + run: cargo clippy --all-targets -- -D warnings + - name: Check formatting + if: matrix.config.kind == 'test_debug' + run: cargo fmt --check - name: Test release if: matrix.config.kind == 'test_release' run: cargo test --release @@ -90,4 +99,3 @@ jobs: ./.github/workflows/ci.ts --lint ./.github/workflows/publish_npm.ts --lint ./.github/workflows/release.ts --lint - ./.github/workflows/check_updates.ts --lint diff --git a/.github/workflows/ci.ts b/.github/workflows/ci.ts index c673662..9dfb5ac 100755 --- a/.github/workflows/ci.ts +++ b/.github/workflows/ci.ts @@ -63,6 +63,9 @@ const buildJob = job("build", { }, { name: "Test debug", if: isDebug, run: "cargo test" }, + { name: "Test tracing", if: isDebug, run: "cargo test --features tracing" }, + { name: "Clippy", if: isDebug, run: "cargo clippy --all-targets -- -D warnings" }, + { name: "Check formatting", if: isDebug, run: "cargo fmt --check" }, { name: "Test release", if: isRelease, run: "cargo test --release" }, getTagVersion, @@ -122,7 +125,6 @@ const buildJob = job("build", { "./.github/workflows/ci.ts --lint", "./.github/workflows/publish_npm.ts --lint", "./.github/workflows/release.ts --lint", - "./.github/workflows/check_updates.ts --lint", ], }, ], diff --git a/CLAUDE.md b/CLAUDE.md index 22fed07..37cbcea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,32 +1,45 @@ # dprint-plugin-sql -Wrapper around [sqlformat-rs](https://github.com/shssoichiro/sqlformat-rs) for use as a dprint formatting plugin. +SQL formatting plugin for dprint, with its own lexer, parser and formatter that build dprint-core's IR. ## Commands - `cargo test` — run all tests +- `cargo test --test specs` — run only the spec tests +- `cargo clippy --all-targets --all-features -- -D warnings` — lint - `cargo build --target wasm32-unknown-unknown --features wasm --release` — build the wasm plugin - `cd deployment/npm && node setup.js && npm test` — test the npm package (requires wasm build first) ## Architecture -- `src/format_text.rs` — core formatting logic, delegates to `sqlformat::format()` +The pipeline is lex → parse → generate IR → print → verify. + +- `src/lexer.rs` — splits the text into tokens, losslessly. This is where the dialects differ (`#`, `[..]`, backslash escapes, dollar quotes, `GO`, `DELIMITER`, client commands) +- `src/parser.rs` — builds statements, procedural blocks (`BEGIN .. END`, `IF`, loops, `CASE` statements, declaration sections), brackets and `CASE` expressions. Also splits T-SQL statements written without semicolons +- `src/ast.rs` — the tree the parser builds +- `src/generation/generate.rs` — the layout: splits statements into clauses, lists and conditions, and generates the IR +- `src/keywords.rs` — sorted tables of keywords +- `src/format_text.rs` — formatting a file, and the verification that formatting changed nothing but whitespace (keyword casing aside), which errors rather than return changed code +- `src/format_range.rs` — formatting the top level statements a range touches - `src/wasm_plugin.rs` — dprint wasm plugin interface (compiled only for wasm32 target with `wasm` feature) - `src/configuration/` — config types, resolution, and builder - `deployment/npm/` — npm package that ships `plugin.wasm` - `deployment/schema.json` — JSON schema for plugin configuration -- `scripts/update.ts` — Deno script that checks crates.io for new sqlformat versions and auto-publishes - `scripts/generateReleaseNotes.ts` — generates changelog for GitHub releases -## Adding a new config option +## Principles -When sqlformat-rs adds new options: +- Formatting only ever changes the whitespace between tokens. Anything the parser doesn't understand is written as a run of tokens rather than reported, since the dialect it's in may use those words differently +- When the dialects disagree on what some text is, the configured dialect decides. The generic dialect must pick what's least likely to be wrong, since a wrong guess there is the most likely kind of bug +- Formatting must be stable: formatting the output again gives the same output. Every spec is formatted twice to check this, and `tests/test.rs` also formats every spec's input in every dialect at two widths and checks it's stable and changes nothing but whitespace + +## Adding a new config option 1. Add the field to `Configuration` in `src/configuration/configuration.rs` (add enums with `generate_str_to_from!` if needed) 2. Add resolution in `src/configuration/resolve_config.rs` using `get_value()` 3. Add a builder method in `src/configuration/builder.rs` and update the `check_all_values_set` test -4. Wire it through in `src/format_text.rs` when constructing `FormatOptions` -5. Add the property to `deployment/schema.json` +4. Use it in `src/generation/generate.rs` +5. Add the property to `deployment/schema.json` and the table in `README.md` 6. Add a spec test file in `tests/specs/Config/` ## Test specs @@ -36,10 +49,11 @@ Spec files live in `tests/specs/` and use the dprint-development format: - `== message ==` starts a test case - `[expect]` separates input from expected output - `~~ key: value, key: value ~~` at the top of a file sets config for all specs in that file -- Each config variation needs its own file since config is file-level +- Each config variation needs its own file since config is file-level, which is why each dialect has its own directory +- `[|` and `|]` mark the range to format in a spec's text +- A spec file can't start with a SQL comment, since a first line starting with `--` names the spec's file ## Releases - Manual: trigger the `release` workflow with patch/minor -- Automatic: `check_updates` workflow runs daily, detects new sqlformat versions, and auto-publishes - On tag push: CI creates a GitHub release with `plugin.wasm`, and `publish_npm` publishes to npm diff --git a/Cargo.lock b/Cargo.lock index 319c826..b4e7332 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.104" @@ -23,6 +29,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -87,21 +102,25 @@ dependencies = [ [[package]] name = "dprint-core" -version = "0.69.1" +version = "0.71.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67359cea061dd052fca921d3589a9ce59bc16fa23bfb9d06d5c669a1dbd23915" +checksum = "7e70ef7f0176218b94519b7e72448f002b6df3536f4f6b6a67fde414a95ed049" dependencies = [ + "bumpalo", + "hashbrown 0.15.5", "indexmap", + "rustc-hash", "serde", "serde_json", "thiserror", + "unicode-width", ] [[package]] name = "dprint-development" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90b3b095f94733262261bfeb860a6550e1d656dd108240c43004bd0f1b419837" +checksum = "b6696b3acf01bba505d35c400115ce277e6a5208ce41defa11799c53f6d02c5e" dependencies = [ "console", "file_test_runner", @@ -117,7 +136,7 @@ dependencies = [ "dprint-development", "serde", "serde_json", - "sqlformat", + "thiserror", ] [[package]] @@ -153,6 +172,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -166,7 +202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -303,6 +339,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "scopeguard" version = "1.2.0" @@ -365,16 +407,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "sqlformat" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0705994df478b895f05b8e290e0d46e53187b26f8d889d37b2a0881234922d94" -dependencies = [ - "unicode_categories", - "winnow", -] - [[package]] name = "syn" version = "3.0.3" @@ -427,12 +459,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - [[package]] name = "winapi-util" version = "0.1.11" @@ -457,15 +483,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index f88578a..b8aa977 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://github.com/dprint/dprint-plugin-sql" keywords = ["formatting", "formatter", "sql"] license = "MIT" repository = "https://github.com/dprint/dprint-plugin-sql" -description = "SQL formatter for dprint via sqlformat-rs." +description = "SQL formatter for dprint." [lib] crate-type = ["lib", "cdylib"] @@ -22,13 +22,19 @@ panic = "abort" [features] wasm = ["serde_json", "dprint-core/wasm"] +tracing = ["dprint-core/tracing"] + +[[test]] +name = "specs" +path = "tests/spec_test.rs" +harness = false [dependencies] -dprint-core = { version = "0.69.1", default-features = false } +dprint-core = { version = "0.71.0", features = ["formatting"], default-features = false } serde = { version = "1.0.88", features = ["derive"] } serde_json = { version = "1.0", optional = true } -sqlformat = "0.5.0" +thiserror = "2" [dev-dependencies] -dprint-development = "0.11.0" +dprint-development = "0.12.1" serde_json = { version = "1.0" } diff --git a/README.md b/README.md index cbf0eb3..980ced0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [![npm version](https://img.shields.io/npm/v/@dprint/sql.svg)](https://www.npmjs.com/package/@dprint/sql) [![CI](https://github.com/dprint/dprint-plugin-sql/workflows/CI/badge.svg)](https://github.com/dprint/dprint-plugin-sql/actions?query=workflow%3ACI) -Wrapper around [sqlformat-rs](https://github.com/shssoichiro/sqlformat-rs) for use as a formatting plugin for [dprint](https://github.com/dprint/dprint). +SQL formatting plugin for [dprint](https://github.com/dprint/dprint). + +It formats the SQL of PostgreSQL, MySQL and MariaDB, SQL Server (T-SQL), SQLite, Oracle (PL/SQL), BigQuery and Snowflake, including stored procedures, functions, triggers and the other procedural code in them. ## Install @@ -15,3 +17,62 @@ dprint add sql # or install from npm dprint add npm:@dprint/sql ``` + +## Configuration + +To add configuration, specify a `"sql"` key in your dprint.json: + +```jsonc +{ + "sql": { + "dialect": "postgresql", + "casing": "upper", + "lineWidth": 100, + }, + "plugins": [ + // ...etc... + ], +} +``` + +| Option | Default | Description | +| --------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `dialect` | `generic` | The dialect the files are written in: `generic`, `postgresql`, `mysql`, `sqlserver`, `sqlite`, `oracle`, `bigquery` or `snowflake`. | +| `casing` | `preserve` | The casing of keywords: `preserve`, `upper` or `lower`. Names and functions are left as written. | +| `lineWidth` | `120` | The width of a line the printer tries to stay under. | +| `indentWidth` | `2` | The number of spaces of an indent. | +| `useTabs` | `false` | Whether to indent with tabs. | +| `newLineKind` | `lf` | The kind of newline: `auto`, `crlf`, `lf` or `system`. | +| `linesBetweenQueries` | `1` | The minimum number of line breaks between statements at the top level of the file. | +| `joinsAsTopLevel` | `false` | Whether to write joins at the indentation of `FROM` rather than indented beneath it. | + +The `uppercase` option of earlier versions is still accepted: `uppercase: true` is the same as `casing: upper`. + +### Dialects + +The dialect decides how the text is split into tokens, which is where the dialects disagree: whether `#` starts a comment (MySQL, BigQuery), names a temporary table (SQL Server) or is an operator (PostgreSQL), whether a backslash escapes a quote in a string (MySQL, BigQuery, Snowflake), whether `[name]` is a quoted identifier (SQL Server, SQLite), and so on. + +The `generic` dialect accepts the syntax of all of them where they don't conflict, and works out the rest from what's around it. Set the dialect when a file doesn't format as expected, or formatting reports a string that isn't terminated. + +Client commands are kept as they are: `GO` and `sqlcmd` commands for SQL Server, `DELIMITER` for MySQL, `/` and SQL\*Plus commands for Oracle, and psql's meta-commands and the data of a `COPY .. FROM STDIN` for PostgreSQL. + +### Procedural code + +Stored procedures, functions, triggers and anonymous blocks are formatted along with the statements in them, for the procedural syntax of each dialect: PL/pgSQL, MySQL's compound statements, T-SQL (including statements without semicolons), PL/SQL packages and procedures, and the scripting of BigQuery and Snowflake. + +The body of a PostgreSQL or Snowflake routine in a dollar-quoted string (`$$ .. $$`) is formatted when its language is SQL or PL/pgSQL, as is the body of a `DO` block. Bodies in other languages are left as they are. + +### Ignoring statements + +A statement after a `-- dprint-ignore` comment is left as it is, and a file starting with a `-- dprint-ignore-file` comment isn't formatted. + +```sql +-- dprint-ignore +SELECT a, b +FROM t; +``` + +## JS Formatting API + +- [JS Formatter](https://github.com/dprint/js-formatter) - Browser/Deno and Node +- [npm package](https://www.npmjs.com/package/@dprint/sql) diff --git a/deployment/npm/README.md b/deployment/npm/README.md index 71f875b..be1b447 100644 --- a/deployment/npm/README.md +++ b/deployment/npm/README.md @@ -1,6 +1,6 @@ # @dprint/sql -npm distribution of [dprint-plugin-sql](https://github.com/dprint/dprint-plugin-sql) which wraps [sqlformat-rs](https://github.com/shssoichiro/sqlformat-rs). +npm distribution of [dprint-plugin-sql](https://github.com/dprint/dprint-plugin-sql), a SQL formatter. Use this with [@dprint/formatter](https://github.com/dprint/js-formatter) or just use @dprint/formatter and download the [dprint-plugin-sql WASM file](https://github.com/dprint/dprint-plugin-sql/releases). diff --git a/deployment/npm/index.test.js b/deployment/npm/index.test.js index 6369522..ea3f773 100644 --- a/deployment/npm/index.test.js +++ b/deployment/npm/index.test.js @@ -9,4 +9,4 @@ const result = formatter.formatText({ fileText: "SELECT * FROM dbo.Test", }); -assert.strictEqual(result, "SELECT\n *\nFROM\n dbo.Test\n"); +assert.strictEqual(result, "SELECT *\nFROM dbo.Test\n"); diff --git a/deployment/schema.json b/deployment/schema.json index d47d859..8351ee0 100644 --- a/deployment/schema.json +++ b/deployment/schema.json @@ -35,6 +35,11 @@ } }, "properties": { + "lineWidth": { + "description": "The width of a line the printer will try to stay under. Note that the printer may exceed this width in certain cases.", + "default": 120, + "type": "number" + }, "indentWidth": { "description": "The number of characters for an indent.", "default": 2, @@ -46,50 +51,66 @@ "newLineKind": { "$ref": "#/definitions/newLineKind" }, - "uppercase": { - "description": "Deprecated. Use 'casing' instead. Use ALL CAPS for reserved words.", - "default": false, - "type": "boolean" + "dialect": { + "description": "The SQL dialect the files are written in, which decides how the text is split into tokens: which characters start a comment or quote an identifier, whether a backslash escapes a quote, and so on. The generic dialect accepts the syntax of all of the others where they don't conflict.", + "type": "string", + "default": "generic", + "oneOf": [{ + "const": "generic", + "description": "The syntax of all of the dialects where they don't conflict." + }, { + "const": "postgresql", + "description": "PostgreSQL, which also suits databases that use its syntax (ex. CockroachDB, DuckDB and Redshift)." + }, { + "const": "mysql", + "description": "MySQL and MariaDB." + }, { + "const": "sqlserver", + "description": "Microsoft SQL Server and Azure SQL (T-SQL)." + }, { + "const": "sqlite", + "description": "SQLite." + }, { + "const": "oracle", + "description": "Oracle (PL/SQL)." + }, { + "const": "bigquery", + "description": "Google BigQuery." + }, { + "const": "snowflake", + "description": "Snowflake." + }] }, "casing": { - "description": "Controls the casing of reserved words.", + "description": "The casing of keywords. Names (ex. of tables and columns) and functions are left as written.", "type": "string", "default": "preserve", "oneOf": [{ "const": "preserve", - "description": "Preserves the original casing of reserved words." + "description": "Preserves the casing of keywords." }, { "const": "upper", - "description": "Converts reserved words to UPPERCASE." + "description": "Converts keywords to UPPERCASE." }, { "const": "lower", - "description": "Converts reserved words to lowercase." + "description": "Converts keywords to lowercase." }] }, + "uppercase": { + "description": "Deprecated. Use 'casing' instead. Use ALL CAPS for keywords.", + "default": false, + "type": "boolean" + }, "linesBetweenQueries": { - "description": "Number of line breaks between queries.", + "description": "The minimum number of line breaks between statements at the top level of the file. A blank line between statements is kept regardless.", "default": 1, - "type": "number" + "type": "integer", + "minimum": 1 }, "joinsAsTopLevel": { - "description": "Consider JOIN statements as top-level keywords instead of reserved keywords.", + "description": "Whether to write joins at the indentation of the FROM keyword rather than indented beneath it.", "default": false, "type": "boolean" - }, - "dialect": { - "description": "The SQL dialect to use for parsing.", - "type": "string", - "default": "generic", - "oneOf": [{ - "const": "generic", - "description": "Generic SQL syntax." - }, { - "const": "postgresql", - "description": "PostgreSQL syntax with array syntax and operators." - }, { - "const": "sqlserver", - "description": "SQL Server syntax with bracketed identifiers and @variables." - }] } } } diff --git a/scripts/update.ts b/scripts/update.ts deleted file mode 100644 index cf741b8..0000000 --- a/scripts/update.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * This script checks for any sqlformat updates and then automatically - * publishes a new version of the plugin if so. - */ -import { $, CargoToml, semver } from "automation"; - -const rootDirPath = $.path(import.meta.dirname!).parentOrThrow(); -const cargoToml = new CargoToml(rootDirPath.join("Cargo.toml")); -const currentVersion = getSqlformatVersion(cargoToml.text()); - -$.logStep("Getting latest version..."); -const latestVersion = await getLatestSqlformatVersion(); -if (semver.compare(currentVersion, latestVersion) >= 0) { - $.log("No new update found. Exiting."); - Deno.exit(0); -} - -$.log("Found new version."); -const isPatchBump = currentVersion.major === latestVersion.major - && currentVersion.minor === latestVersion.minor; -const currentVersionStr = `${currentVersion.major}.${currentVersion.minor}.${currentVersion.patch}`; -const latestVersionStr = `${latestVersion.major}.${latestVersion.minor}.${latestVersion.patch}`; - -$.logStep("Updating rust-toolchain.toml..."); -await updateRustToolchain(latestVersionStr); - -$.logStep("Updating Cargo.toml..."); -cargoToml.replaceAll(`sqlformat = "${currentVersionStr}"`, `sqlformat = "${latestVersionStr}"`); - -// run the tests -$.logStep("Running tests..."); -await $`cargo test`; - -if (Deno.args.includes("--skip-publish")) { - Deno.exit(0); -} - -$.logStep(`Committing sqlformat version bump commit...`); -await $`git add .`; -const message = `${isPatchBump ? "fix" : "feat"}: update to sqlformat ${latestVersionStr}`; -await $`git commit -m ${message}`; - -$.logStep("Bumping version in Cargo.toml..."); -cargoToml.bumpCargoTomlVersion(isPatchBump ? "patch" : "minor"); - -// release -const newVersion = cargoToml.version(); -$.logStep(`Committing and publishing ${newVersion}...`); -await $`git add .`; -await $`git commit -m ${newVersion}`; -await $`git push origin main`; -await $`git tag ${newVersion}`; -await $`git push origin ${newVersion}`; - -function getSqlformatVersion(text: string) { - const match = text.match(/sqlformat\s*=\s*"([^"]+)"/); - const version = match?.[1]; - if (version == null) { - throw new Error("Could not find sqlformat version in Cargo.toml."); - } - $.logLight("Found sqlformat version in Cargo.toml:", version); - return semver.parse(version); -} - -async function getLatestSqlformatVersion() { - const data = await $.request("https://crates.io/api/v1/crates/sqlformat") - .header("User-Agent", "dprint-plugin-sql update script") - .json(); - const version = data.crate.max_stable_version ?? data.crate.max_version; - $.logLight("Latest sqlformat version:", version); - return semver.parse(version); -} - -async function updateRustToolchain(sqlformatVersion: string) { - const data = await $.request(`https://crates.io/api/v1/crates/sqlformat/${sqlformatVersion}`) - .header("User-Agent", "dprint-plugin-sql update script") - .json(); - const requiredRustVersion = data.version?.rust_version; - if (requiredRustVersion == null) { - $.log(`sqlformat ${sqlformatVersion} does not declare a rust_version; leaving rust-toolchain.toml alone.`); - return; - } - - const toolchainPath = rootDirPath.join("rust-toolchain.toml"); - const localContent = toolchainPath.readTextSync(); - const localMatch = localContent.match(/channel\s*=\s*"([^"]+)"/); - if (localMatch == null) { - throw new Error("Could not find channel in local rust-toolchain.toml."); - } - // crates.io rust_version may be "1.84" (no patch); pad to MAJOR.MINOR.PATCH - // so @std/semver can parse it. - const normalize = (v: string) => /^\d+\.\d+$/.test(v) ? `${v}.0` : v; - const local = semver.parse(normalize(localMatch[1])); - const required = semver.parse(normalize(requiredRustVersion)); - if (semver.greaterThan(required, local)) { - $.log(`Updating Rust toolchain: ${localMatch[1]} -> ${requiredRustVersion}`); - toolchainPath.writeTextSync(localContent.replace(localMatch[0], `channel = "${requiredRustVersion}"`)); - } else { - $.log( - `Rust toolchain at ${ - localMatch[1] - } already satisfies sqlformat ${sqlformatVersion} (needs >= ${requiredRustVersion}).`, - ); - } -} diff --git a/src/ast.rs b/src/ast.rs new file mode 100644 index 0000000..fdcb2ee --- /dev/null +++ b/src/ast.rs @@ -0,0 +1,345 @@ +// AST types for the SQL parser. +// +// The tree only records the structure that decides where lines break: the statements, the blocks +// of procedural code they contain, and the brackets and `CASE` expressions within them. What +// happens between those (the clauses of a query, the items of a list, the operands of an +// operator) is worked out from the tokens when the tree is generated, since that's where the +// dialects differ the most and a mistake should cost no more than a less pleasing layout. +// +// Every token in the tree is borrowed from the lexer's output, and every token of the input is in +// the tree exactly once, in its original order. Comments are tokens like any other, apart from the +// ones on lines of their own between statements, which are items of the statement list. + +use crate::lexer::Token; +use crate::lexer::TokenKind; + +pub type Tok<'a> = &'a Token<'a>; + +/// A list of statements: the file, or the body of a block. +#[derive(Debug, Default)] +pub struct StatementList<'a> { + pub items: Vec>, +} + +#[derive(Debug)] +pub struct Item<'a> { + pub kind: ItemKind<'a>, + /// Whether a blank line separates the item from the one before it. + pub blank_line_before: bool, +} + +#[derive(Debug)] +pub enum ItemKind<'a> { + Statement(Statement<'a>), + /// A comment on a line of its own. + Comment(Tok<'a>), + /// A batch separator (`GO`) or a command to the client (`DELIMITER //`), along with any + /// comments after it on its line. + Command(Tok<'a>, Vec>), +} + +impl<'a> Item<'a> { + pub fn first_token(&self) -> Option> { + match &self.kind { + ItemKind::Statement(statement) => statement.first_token(), + ItemKind::Comment(token) | ItemKind::Command(token, _) => Some(token), + } + } + + pub fn last_token(&self) -> Option> { + match &self.kind { + ItemKind::Statement(statement) => statement.last_token(), + ItemKind::Comment(token) => Some(token), + ItemKind::Command(token, comments) => Some(comments.last().copied().unwrap_or(token)), + } + } +} + +#[derive(Debug)] +pub struct Statement<'a> { + pub elements: Vec>, + /// The `;` (or `DELIMITER` set terminator) that ends the statement, if any. + pub terminator: Option>, + /// Comments on the same line as the end of the statement. + pub trailing_comments: Vec>, + /// Whether a `dprint-ignore` comment precedes the statement, so it's written as it is. + pub ignored: bool, +} + +impl<'a> Statement<'a> { + pub fn first_token(&self) -> Option> { + self + .elements + .first() + .map(|element| element.first_token()) + .or(self.terminator) + } + + pub fn last_token(&self) -> Option> { + self + .trailing_comments + .last() + .copied() + .or(self.terminator) + .or_else(|| self.elements.last().map(|element| element.last_token())) + } +} + +#[derive(Debug)] +pub enum Element<'a> { + Token(Tok<'a>), + /// Brackets of any kind (`( )`, `[ ]` or `{ }`) and what's within them. + Group(Box>), + Case(Box>), + /// `BEGIN .. END`, including T-SQL's `BEGIN TRY .. END TRY` and `BEGIN CATCH .. END CATCH`. + Block(Box>), + /// The declarations before a block's `BEGIN` (ex. after `DECLARE`, or after the `IS` of an + /// Oracle procedure). + DeclareSection(Box>), + /// `IF cond THEN .. END IF`. + If(Box>), + /// T-SQL's `IF cond statement ELSE statement` and `WHILE cond statement`. + Conditional(Box>), + /// `LOOP`, `WHILE`, `FOR`, `FOREACH` and `REPEAT` loops with a body ended by `END ..`. + Loop(Box>), + /// A procedural `CASE .. END CASE` whose branches hold statements. + CaseStatement(Box>), + /// A label for a block or loop (`<>` or `name:`). + Label(Box>), +} + +impl<'a> Element<'a> { + pub fn first_token(&self) -> Tok<'a> { + match self { + Element::Token(token) => token, + Element::Group(group) => group.open, + Element::Case(case) => case.case_keyword, + Element::Block(block) => block.begin[0], + Element::DeclareSection(section) => section.first_token(), + Element::If(block) => block.branches[0].keyword, + Element::Conditional(conditional) => conditional.keyword, + Element::Loop(block) => block.header[0].first_token(), + Element::CaseStatement(statement) => statement.case_keyword, + Element::Label(label) => label.tokens[0], + } + } + + pub fn last_token(&self) -> Tok<'a> { + match self { + Element::Token(token) => token, + Element::Group(group) => group.close, + Element::Case(case) => case.end, + Element::Block(block) => block.end.last().unwrap(), + Element::DeclareSection(section) => section.last_token(), + Element::If(block) => block.end.last().unwrap(), + Element::Conditional(conditional) => conditional.last_token(), + Element::Loop(block) => block.end.last().unwrap(), + Element::CaseStatement(statement) => statement.end.last().unwrap(), + Element::Label(label) => label.tokens.last().unwrap(), + } + } + + pub fn as_token(&self) -> Option> { + match self { + Element::Token(token) => Some(token), + _ => None, + } + } + + pub fn is_word(&self, word: &str) -> bool { + self.as_token().is_some_and(|token| token.is_word(word)) + } + + pub fn is_kind(&self, kind: TokenKind) -> bool { + self.as_token().is_some_and(|token| token.kind == kind) + } + + pub fn is_comment(&self) -> bool { + self.as_token().is_some_and(|token| token.is_comment()) + } + + pub fn is_line_comment(&self) -> bool { + self.as_token().is_some_and(|token| token.is_line_comment()) + } + + /// Whether the element is procedural code that is written over lines of its own. + pub fn is_procedural(&self) -> bool { + matches!( + self, + Element::Block(_) + | Element::DeclareSection(_) + | Element::If(_) + | Element::Conditional(_) + | Element::Loop(_) + | Element::CaseStatement(_) + ) + } +} + +#[derive(Debug)] +pub struct Group<'a> { + pub open: Tok<'a>, + pub elements: Vec>, + pub close: Tok<'a>, +} + +impl Group<'_> { + pub fn is_paren(&self) -> bool { + self.open.kind == TokenKind::OpenParen + } +} + +/// `CASE [operand] WHEN .. THEN .. [ELSE ..] END`. +#[derive(Debug)] +pub struct Case<'a> { + pub case_keyword: Tok<'a>, + pub operand: Vec>, + pub branches: Vec>, + pub else_branch: Option>, + /// Comments on lines of their own before the `END`. + pub end_comments: Vec>, + pub end: Tok<'a>, +} + +#[derive(Debug)] +pub struct CaseBranch<'a> { + /// Comments on lines of their own before the `WHEN`. + pub leading_comments: Vec>, + pub when: Tok<'a>, + pub condition: Vec>, + pub then: Tok<'a>, + pub result: Vec>, +} + +#[derive(Debug)] +pub struct CaseElse<'a> { + pub leading_comments: Vec>, + pub keyword: Tok<'a>, + pub result: Vec>, +} + +#[derive(Debug)] +pub struct Block<'a> { + /// `BEGIN` along with any words after it that say what kind of block it is (ex. `TRY`). + pub begin: Vec>, + pub body: StatementList<'a>, + pub exception: Option>, + /// `END` along with any words after it (ex. `TRY` or the name of the procedure). + pub end: Vec>, +} + +#[derive(Debug)] +pub struct ExceptionSection<'a> { + pub keyword: Tok<'a>, + /// Comments before the first handler. + pub leading_comments: Vec>, + pub handlers: Vec>, +} + +/// `WHEN condition THEN statements` in a list of exception handlers or a procedural `CASE`. +#[derive(Debug)] +pub struct Handler<'a> { + pub when: Tok<'a>, + pub condition: Vec>, + pub then: Tok<'a>, + pub body: StatementList<'a>, +} + +#[derive(Debug)] +pub struct DeclareSection<'a> { + pub keyword: Option>, + pub declarations: StatementList<'a>, +} + +impl<'a> DeclareSection<'a> { + pub fn first_token(&self) -> Tok<'a> { + self + .keyword + .or_else(|| self.declarations.items.first().and_then(|item| item.first_token())) + .unwrap() + } + + pub fn last_token(&self) -> Tok<'a> { + self + .declarations + .items + .last() + .and_then(|item| item.last_token()) + .or(self.keyword) + .unwrap() + } +} + +#[derive(Debug)] +pub struct IfBlock<'a> { + pub branches: Vec>, + pub else_branch: Option>, + /// `END IF`. + pub end: Vec>, +} + +#[derive(Debug)] +pub struct IfBranch<'a> { + /// `IF`, `ELSIF` or `ELSEIF`. + pub keyword: Tok<'a>, + pub condition: Vec>, + pub then: Tok<'a>, + pub body: StatementList<'a>, +} + +#[derive(Debug)] +pub struct ElseBranch<'a> { + pub keyword: Tok<'a>, + pub body: StatementList<'a>, +} + +/// T-SQL's `IF condition statement [ELSE statement]` or `WHILE condition statement`, where each +/// statement may be a block. +#[derive(Debug)] +pub struct Conditional<'a> { + pub keyword: Tok<'a>, + pub condition: Vec>, + pub body: Box>, + /// Comments on lines of their own between the body and the `ELSE`. + pub else_comments: Vec>, + pub else_branch: Option<(Tok<'a>, Box>)>, +} + +impl<'a> Conditional<'a> { + pub fn last_token(&self) -> Tok<'a> { + match &self.else_branch { + Some((keyword, statement)) => statement.last_token().unwrap_or(keyword), + None => self.body.last_token().unwrap_or(self.keyword), + } + } +} + +#[derive(Debug)] +pub struct Loop<'a> { + /// What comes before the body: `WHILE condition` or `FOR .. IN ..`, followed by the `LOOP` or + /// `DO`. Just `LOOP` or `REPEAT` for loops without a header. + pub header: Vec>, + pub body: StatementList<'a>, + /// The `UNTIL` of a `REPEAT` loop and its condition. + pub until: Option<(Tok<'a>, Vec>)>, + /// `END LOOP`, `END WHILE`, `END FOR` or `END REPEAT`, followed by any label. + pub end: Vec>, +} + +#[derive(Debug)] +pub struct CaseStatement<'a> { + pub case_keyword: Tok<'a>, + pub operand: Vec>, + pub branches: Vec>, + pub else_branch: Option>, + /// `END CASE`. + pub end: Vec>, +} + +#[derive(Debug)] +pub struct Label<'a> { + pub tokens: Vec>, + /// Whether the label is written on a line of its own (ex. `<>`) rather than before what + /// it labels (ex. `name: LOOP`). + pub own_line: bool, +} diff --git a/src/configuration/builder.rs b/src/configuration/builder.rs index a8c2abd..d22edbc 100644 --- a/src/configuration/builder.rs +++ b/src/configuration/builder.rs @@ -1,6 +1,7 @@ -use dprint_core::configuration::{ - resolve_global_config, ConfigKeyMap, ConfigKeyValue, GlobalConfiguration, NewLineKind, -}; +use dprint_core::configuration::ConfigKeyMap; +use dprint_core::configuration::ConfigKeyValue; +use dprint_core::configuration::GlobalConfiguration; +use dprint_core::configuration::NewLineKind; use super::*; @@ -15,6 +16,7 @@ use super::*; /// .casing(UppercaseKind::Upper) /// .build(); /// ``` +#[derive(Default)] pub struct ConfigurationBuilder { pub(super) config: ConfigKeyMap, global_config: Option, @@ -23,10 +25,7 @@ pub struct ConfigurationBuilder { impl ConfigurationBuilder { /// Constructs a new configuration builder. pub fn new() -> ConfigurationBuilder { - ConfigurationBuilder { - config: Default::default(), - global_config: None, - } + ConfigurationBuilder::default() } /// Gets the final configuration that can be used to format a file. @@ -34,7 +33,7 @@ impl ConfigurationBuilder { if let Some(global_config) = &self.global_config { resolve_config(self.config.clone(), global_config).config } else { - let global_config = resolve_global_config(&mut Default::default()).config; + let global_config = GlobalConfiguration::default(); resolve_config(self.config.clone(), &global_config).config } } @@ -45,6 +44,13 @@ impl ConfigurationBuilder { self } + /// The width of a line the printer will try to stay under. Note that the printer may exceed this width in certain cases. + /// + /// Default: `120` + pub fn line_width(&mut self, value: u32) -> &mut Self { + self.insert("lineWidth", (value as i32).into()) + } + /// Whether to use tabs (true) or spaces (false). /// /// Default: `false` @@ -60,30 +66,35 @@ impl ConfigurationBuilder { } /// The kind of newline to use. + /// /// Default: `NewLineKind::LineFeed` pub fn new_line_kind(&mut self, value: NewLineKind) -> &mut Self { self.insert("newLineKind", value.to_string().into()) } - /// Controls casing of reserved words. + /// The casing of keywords. + /// /// Default: `UppercaseKind::Preserve` pub fn casing(&mut self, value: UppercaseKind) -> &mut Self { self.insert("casing", value.to_string().into()) } - /// Number of line breaks between queries. + /// The minimum number of line breaks between statements at the top level of the file. + /// /// Default: `1` pub fn lines_between_queries(&mut self, value: u8) -> &mut Self { self.insert("linesBetweenQueries", (value as i32).into()) } - /// Consider JOIN statements as top-level keywords. + /// Whether to write joins at the same indentation as the `FROM` keyword rather than indented beneath it. + /// /// Default: `false` pub fn joins_as_top_level(&mut self, value: bool) -> &mut Self { self.insert("joinsAsTopLevel", value.into()) } - /// The SQL dialect to use. + /// The SQL dialect, which decides how the text is split into tokens. + /// /// Default: `Dialect::Generic` pub fn dialect(&mut self, value: Dialect) -> &mut Self { self.insert("dialect", value.to_string().into()) @@ -102,7 +113,8 @@ impl ConfigurationBuilder { #[cfg(test)] mod tests { - use dprint_core::configuration::{resolve_global_config, NewLineKind}; + use dprint_core::configuration::NewLineKind; + use dprint_core::configuration::resolve_global_config; use super::*; @@ -110,6 +122,7 @@ mod tests { fn check_all_values_set() { let mut config = ConfigurationBuilder::new(); config + .line_width(80) .new_line_kind(NewLineKind::CarriageReturnLineFeed) .use_tabs(true) .indent_width(4) @@ -119,25 +132,23 @@ mod tests { .dialect(Dialect::PostgreSql); let inner_config = config.get_inner_config(); - assert_eq!(inner_config.len(), 7); - let diagnostics = resolve_config( - inner_config, - &resolve_global_config(&mut Default::default()).config, - ) - .diagnostics; + assert_eq!(inner_config.len(), 8); + let diagnostics = resolve_config(inner_config, &resolve_global_config(&mut Default::default()).config).diagnostics; assert_eq!(diagnostics.len(), 0); } #[test] fn handle_global_config() { let mut global_config = ConfigKeyMap::new(); + global_config.insert(String::from("lineWidth"), 90.into()); global_config.insert(String::from("newLineKind"), "crlf".into()); global_config.insert(String::from("useTabs"), true.into()); let global_config = resolve_global_config(&mut global_config).config; let mut config_builder = ConfigurationBuilder::new(); let config = config_builder.global_config(global_config).build(); - assert_eq!(config.new_line_kind == NewLineKind::CarriageReturnLineFeed, true); - assert_eq!(config.use_tabs, true); + assert_eq!(config.line_width, 90); + assert!(config.new_line_kind == NewLineKind::CarriageReturnLineFeed); + assert!(config.use_tabs); } #[test] @@ -145,7 +156,28 @@ mod tests { let global_config = resolve_global_config(&mut Default::default()).config; let mut config_builder = ConfigurationBuilder::new(); let config = config_builder.global_config(global_config).build(); + assert_eq!(config.line_width, 120); assert_eq!(config.indent_width, 2); - assert_eq!(config.new_line_kind == NewLineKind::LineFeed, true); + assert!(config.new_line_kind == NewLineKind::LineFeed); + } + + #[test] + fn accepts_every_dialect() { + for dialect in [ + "generic", + "postgresql", + "mysql", + "sqlserver", + "sqlite", + "oracle", + "bigquery", + "snowflake", + ] { + let mut config = ConfigKeyMap::new(); + config.insert(String::from("dialect"), dialect.into()); + let result = resolve_config(config, &resolve_global_config(&mut Default::default()).config); + assert_eq!(result.diagnostics.len(), 0, "{dialect}"); + assert_eq!(result.config.dialect.to_string(), dialect); + } } } diff --git a/src/configuration/configuration.rs b/src/configuration/configuration.rs index 7e7c639..b07c713 100644 --- a/src/configuration/configuration.rs +++ b/src/configuration/configuration.rs @@ -1,16 +1,18 @@ use dprint_core::configuration::NewLineKind; use dprint_core::configuration::ParseConfigurationError; use dprint_core::generate_str_to_from; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use serde::Serialize; -#[derive(Clone, PartialEq, Copy, Serialize, Deserialize)] +/// The casing of keywords. +#[derive(Clone, PartialEq, Eq, Debug, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum UppercaseKind { /// Preserve original keyword casing. Preserve, - /// Convert reserved words to UPPERCASE. + /// Convert keywords to UPPERCASE. Upper, - /// Convert reserved words to lowercase. + /// Convert keywords to lowercase. Lower, } @@ -21,27 +23,47 @@ generate_str_to_from![ [Lower, "lower"] ]; -#[derive(Clone, PartialEq, Copy, Serialize, Deserialize)] +/// The SQL dialect, which decides how the text is split into tokens: which characters start a +/// comment or quote an identifier, whether a backslash escapes a quote, and so on. +#[derive(Clone, PartialEq, Eq, Debug, Copy, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum Dialect { - /// Generic SQL syntax. + /// Accepts the syntax of all of the dialects where they don't conflict. Generic, - /// PostgreSQL syntax. + /// PostgreSQL, which also suits other databases that use its syntax (ex. CockroachDB, DuckDB, + /// Redshift). PostgreSql, - /// SQL Server syntax. + /// MySQL and MariaDB. + MySql, + /// Microsoft SQL Server and Azure SQL (T-SQL). SqlServer, + /// SQLite. + Sqlite, + /// Oracle (PL/SQL). + Oracle, + /// Google BigQuery. + BigQuery, + /// Snowflake. + Snowflake, } generate_str_to_from![ Dialect, [Generic, "generic"], [PostgreSql, "postgresql"], - [SqlServer, "sqlserver"] + [MySql, "mysql"], + [SqlServer, "sqlserver"], + [Sqlite, "sqlite"], + [Oracle, "oracle"], + [BigQuery, "bigquery"], + [Snowflake, "snowflake"] ]; #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Configuration { + pub line_width: u32, pub use_tabs: bool, pub indent_width: u8, pub new_line_kind: NewLineKind, diff --git a/src/configuration/mod.rs b/src/configuration/mod.rs index 357e324..be38c0e 100644 --- a/src/configuration/mod.rs +++ b/src/configuration/mod.rs @@ -1,4 +1,5 @@ mod builder; +#[allow(clippy::module_inception)] mod configuration; mod resolve_config; diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index 206ce7f..26197a9 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -36,12 +36,24 @@ pub fn resolve_config( let uppercase_default = if let Some(ConfigKeyValue::Bool(value)) = config.get("uppercase") { let value = *value; config.shift_remove("uppercase"); - if value { UppercaseKind::Upper } else { UppercaseKind::Preserve } + if value { + UppercaseKind::Upper + } else { + UppercaseKind::Preserve + } } else { UppercaseKind::Preserve }; let resolved_config = Configuration { + line_width: get_value( + &mut config, + "lineWidth", + global_config + .line_width + .unwrap_or(RECOMMENDED_GLOBAL_CONFIGURATION.line_width), + &mut diagnostics, + ), use_tabs: get_value( &mut config, "useTabs", diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4bc4b7c --- /dev/null +++ b/src/error.rs @@ -0,0 +1,30 @@ +/// The input could not be parsed as SQL. +/// +/// The message is a pre-rendered diagnostic that includes the offending line +/// and a caret pointing at the position of the error. +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +pub struct ParseError { + message: String, +} + +impl ParseError { + pub(crate) fn new(message: String) -> Self { + ParseError { message } + } +} + +/// An error that can occur while formatting a SQL file. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum FormatError { + /// The input could not be parsed as SQL. + #[error(transparent)] + Parse(#[from] ParseError), + /// Formatting would have changed more than the whitespace between the tokens of the input, + /// which is a bug in the formatter. The input is left as it is rather than risking that. + #[error( + "Formatting would have changed more than the whitespace of the file, so it was left as it is. This is a bug in dprint-plugin-sql. Please report it at https://github.com/dprint/dprint-plugin-sql/issues" + )] + WouldChangeCode, +} diff --git a/src/format_range.rs b/src/format_range.rs new file mode 100644 index 0000000..970800b --- /dev/null +++ b/src/format_range.rs @@ -0,0 +1,246 @@ +use std::borrow::Cow; +use std::ops::Range; +use std::ops::RangeInclusive; +use std::path::Path; + +use dprint_core::configuration::NewLineKind; +use dprint_core::configuration::resolve_new_line_kind; + +use super::configuration::Configuration; +use crate::ast::Item; +use crate::error::FormatError; +use crate::format_text::format_text; +use crate::format_text::has_ignore_file_comment; +use crate::format_text::lex; +use crate::format_text::parse; +use crate::format_text::print_items; +use crate::format_text::strip_bom; +use crate::format_text::verify; + +/// Formats only the part of the text within the provided byte range. +/// +/// The range is widened to the lines of the statements, comments and commands it touches at the +/// top level of the file, and the text outside of those is left as it was, including the blank +/// lines above and below them, the line endings and any byte order mark. An empty range is a +/// cursor, which touches whatever is on its line. Statements that share a line with a touched one +/// are touched too, since only whole lines are formatted. +/// +/// A range that touches both the first and last of the file's statements formats the whole file, +/// and one that only touches blank lines formats nothing. +pub fn format_text_range( + file_path: &Path, + text: &str, + range: Range, + config: &Configuration, +) -> Result, FormatError> { + let body = strip_bom(text); + let bom_len = text.len() - body.len(); + let end = range.end.saturating_sub(bom_len).min(body.len()); + let range = range.start.saturating_sub(bom_len).min(end)..end; + if range.start == 0 && range.end == body.len() { + return format_text(file_path, text, config); + } + + let tokens = lex(body, config.dialect)?; + if has_ignore_file_comment(&tokens) { + return Ok(None); + } + let list = parse(&tokens, body, config.dialect)?; + let items = &list.items; + let spans: Vec> = items.iter().map(item_span).collect(); + let Some(touched) = find_touched(body, items, &spans, &range) else { + return Ok(None); + }; + if touched == (0..=items.len() - 1) { + return format_text(file_path, text, config); + } + + let (first, last) = touched.into_inner(); + let original = lines_range(body, spans[first].start..spans[last].end); + let formatted = print_items(&items[first..=last], body, config); + let spliced = format!( + "{}{}{}", + &body[..original.start], + with_file_new_lines(&formatted, body), + &body[original.end..] + ); + verify(&tokens, &spliced, config)?; + let result = format!("{}{}", &text[..bom_len], spliced); + Ok(if result == text { None } else { Some(result) }) +} + +/// Where the item starts and ends in the text. +fn item_span(item: &Item) -> Range { + match (item.first_token(), item.last_token()) { + (Some(first), Some(last)) => first.start..last.end(), + _ => 0..0, + } +} + +/// The indexes of the items the range touches, if any, along with any that share a line with +/// them. +/// +/// A cursor touches an item from anywhere on its lines, but a selection needs to hold some of the +/// item itself, so that one ending in the indentation of the next line doesn't format that line. +fn find_touched( + text: &str, + items: &[Item], + spans: &[Range], + range: &Range, +) -> Option> { + let mut touched = (0..spans.len()).filter(|index| { + let span = &spans[*index]; + if range.is_empty() { + let lines = lines_range(text, span.clone()); + lines.start <= range.start && range.start <= lines.end + } else { + range.start < span.end && range.end > span.start + } + }); + let mut first = touched.next()?; + let mut last = touched.next_back().unwrap_or(first); + // only whole lines are formatted, so whatever shares a line with a touched item is formatted too + while first > 0 && items[first].first_token().is_some_and(|t| t.newlines_before == 0) { + first -= 1; + } + while last + 1 < items.len() && items[last + 1].first_token().is_some_and(|t| t.newlines_before == 0) { + last += 1; + } + Some(first..=last) +} + +/// Widens the range to the start of its first line, so that the indentation there is formatted, +/// and to the end of its last line without the line break. +fn lines_range(text: &str, range: Range) -> Range { + let start = text[..range.start].rfind('\n').map(|index| index + 1).unwrap_or(0); + let rest = &text[range.end..]; + let line = rest.find('\n').map(|index| &rest[..index]).unwrap_or(rest); + start..range.end + line.trim_end_matches('\r').len() +} + +/// Keeps the line endings of the rest of the file since changing those is up to formatting the +/// whole file. +fn with_file_new_lines<'a>(formatted: &'a str, file_text: &str) -> Cow<'a, str> { + if !file_text.contains('\n') { + return Cow::Borrowed(formatted); + } + match resolve_new_line_kind(file_text, NewLineKind::Auto) { + "\r\n" if formatted.contains('\n') && !formatted.contains("\r\n") => Cow::Owned(formatted.replace('\n', "\r\n")), + "\n" if formatted.contains("\r\n") => Cow::Owned(formatted.replace("\r\n", "\n")), + _ => Cow::Borrowed(formatted), + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::configuration::ConfigurationBuilder; + + fn format_b(config: &Configuration, text: &str) -> String { + let start = text.find('b').unwrap(); + format_text_range(Path::new("file.sql"), text, start..start + 1, config) + .unwrap() + .unwrap() + } + + #[test] + fn keeps_bom_outside_range() { + // the spec files can't express this since editors strip the bom + let config = ConfigurationBuilder::new().build(); + let output = format_b(&config, "\u{FEFF}select a;\nselect b;\nselect c;\n"); + assert_eq!(output, "\u{FEFF}select a;\nselect b;\nselect c;\n"); + } + + #[test] + fn keeps_file_line_endings() { + // the spec files can't express this since they normalize line endings + let config = ConfigurationBuilder::new().build(); + let output = format_b(&config, "select a;\r\nselect b from t;\r\nselect c;\r\n"); + assert_eq!(output, "select a;\r\nselect b\r\nfrom t;\r\nselect c;\r\n"); + } + + #[test] + fn keeps_file_line_endings_over_configured_ones() { + let config = ConfigurationBuilder::new() + .new_line_kind(NewLineKind::CarriageReturnLineFeed) + .build(); + let output = format_b(&config, "select a;\nselect b from t;\nselect c;\n"); + assert_eq!(output, "select a;\nselect b\nfrom t;\nselect c;\n"); + } + + #[test] + fn formats_statement_cursor_is_in_trailing_whitespace_of() { + let config = ConfigurationBuilder::new().build(); + let text = "select a;\nselect b; \nselect c;\n"; + let cursor = text.find("; \n").unwrap() + 3; + let output = format_text_range(Path::new("file.sql"), text, cursor..cursor, &config).unwrap(); + assert_eq!(output.as_deref(), Some("select a;\nselect b;\nselect c;\n")); + } + + #[test] + fn formats_statements_sharing_a_line() { + let config = ConfigurationBuilder::new().build(); + let output = format_b(&config, "select a;\nselect b; select x;\nselect c;\n"); + assert_eq!(output, "select a;\nselect b;\nselect x;\nselect c;\n"); + } + + #[test] + fn formats_last_statement_without_adding_final_newline() { + let config = ConfigurationBuilder::new().build(); + let output = format_b(&config, "select a;\nselect b;"); + assert_eq!(output, "select a;\nselect b;"); + } + + #[test] + fn does_not_format_at_end_of_file() { + let config = ConfigurationBuilder::new().build(); + let text = "select a;\nselect b;\n\n"; + let output = format_text_range(Path::new("file.sql"), text, text.len()..text.len(), &config).unwrap(); + assert_eq!(output, None); + } + + #[test] + fn does_not_format_when_already_formatted() { + let config = ConfigurationBuilder::new().build(); + let text = "select a;\nselect b;\nselect c;\n"; + let start = text.find('b').unwrap(); + let output = format_text_range(Path::new("file.sql"), text, start..start + 1, &config).unwrap(); + assert_eq!(output, None); + } + + #[test] + fn formats_whole_file_when_touching_first_and_last_statements() { + let config = ConfigurationBuilder::new().build(); + let output = format_text_range(Path::new("file.sql"), "\n\nselect a;\nselect b;", 3..20, &config).unwrap(); + assert_eq!(output.as_deref(), Some("select a;\nselect b;\n")); + } + + #[test] + fn clamps_range_past_end_of_file() { + let config = ConfigurationBuilder::new().build(); + let text = "select a;\nselect b;\n\n"; + let output = format_text_range(Path::new("file.sql"), text, 14..500, &config).unwrap(); + assert_eq!(output.as_deref(), Some("select a;\nselect b;\n\n")); + let output = format_text_range(Path::new("file.sql"), text, 400..500, &config).unwrap(); + assert_eq!(output, None); + } + + #[test] + fn errors_for_syntax_error_outside_range() { + let config = ConfigurationBuilder::new().build(); + let result = format_text_range(Path::new("file.sql"), "select a;\nselect 'b\n", 0..1, &config); + assert!(result.unwrap_err().to_string().contains("unterminated string")); + } + + #[test] + fn formats_procedure_touched_within_its_body() { + let config = ConfigurationBuilder::new().build(); + let text = "select a;\ncreate procedure p() begin\nselect 1;\nend;\nselect c;\n"; + let cursor = text.find("1;").unwrap(); + let output = format_text_range(Path::new("file.sql"), text, cursor..cursor, &config).unwrap(); + assert_eq!( + output.as_deref(), + Some("select a;\ncreate procedure p()\nbegin\n select 1;\nend;\nselect c;\n") + ); + } +} diff --git a/src/format_text.rs b/src/format_text.rs index 5594431..a8d9cde 100644 --- a/src/format_text.rs +++ b/src/format_text.rs @@ -1,59 +1,305 @@ -use super::configuration::Configuration; -use super::configuration::UppercaseKind; +use std::path::Path; +use dprint_core::configuration::NewLineKind; use dprint_core::configuration::resolve_new_line_kind; -use sqlformat::FormatOptions; -use sqlformat::Indent; -use sqlformat::QueryParams; -use std::path::Path; +use dprint_core::formatting::PrintOptions; + +use crate::ast::Item; +use crate::ast::StatementList; +use crate::configuration::Configuration; +use crate::configuration::Dialect; +use crate::configuration::UppercaseKind; +use crate::error::FormatError; +use crate::error::ParseError; +use crate::generation::Context; +use crate::generation::generate; +use crate::generation::generate_items; +use crate::lexer; +use crate::lexer::Token; +use crate::lexer::TokenKind; +use crate::parser; + +pub fn format_text(_file_path: &Path, text: &str, config: &Configuration) -> Result, FormatError> { + let result = format_text_inner(text, config)?; + match result { + Some(result) if result != text => Ok(Some(result)), + _ => Ok(None), + } +} + +/// The formatted text, or `None` when a `dprint-ignore-file` comment says to leave it alone. +fn format_text_inner(text: &str, config: &Configuration) -> Result, FormatError> { + let text = strip_bom(text); + let tokens = lex(text, config.dialect)?; + if has_ignore_file_comment(&tokens) { + return Ok(None); + } + let list = parse(&tokens, text, config.dialect)?; + let result = print(&list, text, config); + verify(&tokens, &result, config)?; + Ok(Some(result)) +} + +/// Formats SQL embedded in another file's SQL, such as the body of a PL/pgSQL function, with line +/// feeds for its line breaks. Returns `None` when it can't be formatted, in which case it should +/// be left as it is. +pub(crate) fn format_embedded(text: &str, config: &Configuration) -> Option { + let mut config = config.clone(); + config.new_line_kind = NewLineKind::LineFeed; + let tokens = lexer::lex(text, config.dialect).ok()?; + if has_ignore_file_comment(&tokens) { + return None; + } + let list = parser::parse(&tokens, config.dialect).ok()?; + let result = print(&list, text, &config); + verify(&tokens, &result, &config).ok()?; + Some(result) +} + +pub(crate) fn print(list: &StatementList, text: &str, config: &Configuration) -> String { + dprint_core::formatting::format( + || { + let mut context = Context::new(config, text); + generate(list, &mut context) + }, + config_to_print_options(text, config), + ) +} + +/// Prints the items of the top level statement list, as they're printed when formatting the whole +/// file, but without a newline at the end. +pub(crate) fn print_items(items: &[Item], text: &str, config: &Configuration) -> String { + dprint_core::formatting::format( + || { + let mut context = Context::new(config, text); + generate_items(items, &mut context) + }, + config_to_print_options(text, config), + ) +} -pub fn format_text(_file_path: &Path, text: &str, config: &Configuration) -> Option { - let input_text = text; - let text = sqlformat::format( - text, - &QueryParams::None, - &FormatOptions { - indent: if config.use_tabs { - Indent::Tabs - } else { - Indent::Spaces(config.indent_width) - }, - uppercase: match config.uppercase { - UppercaseKind::Preserve => None, - UppercaseKind::Upper => Some(true), - UppercaseKind::Lower => Some(false), - }, - lines_between_queries: config.lines_between_queries, - joins_as_top_level: config.joins_as_top_level, - dialect: match config.dialect { - super::configuration::Dialect::Generic => sqlformat::Dialect::Generic, - super::configuration::Dialect::PostgreSql => sqlformat::Dialect::PostgreSql, - super::configuration::Dialect::SqlServer => sqlformat::Dialect::SQLServer, - }, - ..Default::default() +#[cfg(feature = "tracing")] +pub fn trace_file(_file_path: &Path, text: &str, config: &Configuration) -> dprint_core::formatting::TracingResult { + let text = strip_bom(text); + let tokens = lexer::lex(text, config.dialect).unwrap(); + let list = parser::parse(&tokens, config.dialect).unwrap(); + dprint_core::formatting::trace_printing( + || { + let mut context = Context::new(config, text); + generate(&list, &mut context) }, - ); + config_to_print_options(text, config), + ) +} + +pub(crate) fn strip_bom(text: &str) -> &str { + text.strip_prefix("\u{FEFF}").unwrap_or(text) +} + +pub(crate) fn lex(text: &str, dialect: Dialect) -> Result>, ParseError> { + lexer::lex(text, dialect).map_err(|err| { + let mut message = err.message; + let is_quote_error = message.starts_with("unterminated string") || message.starts_with("unterminated quoted identifier"); + if is_quote_error && !matches!(dialect, Dialect::MySql | Dialect::BigQuery | Dialect::Snowflake) { + message.push_str(" (if the file is written in a dialect that escapes quotes with a backslash, such as MySQL, set the \"dialect\" option)"); + } + ParseError::new(render_diagnostic(text, err.span, &message)) + }) +} + +pub(crate) fn parse<'a>( + tokens: &'a [Token<'a>], + text: &str, + dialect: Dialect, +) -> Result, ParseError> { + parser::parse(tokens, dialect).map_err(|err| ParseError::new(render_diagnostic(text, err.span, &err.message))) +} + +pub(crate) fn has_ignore_file_comment(tokens: &[Token]) -> bool { + tokens + .iter() + .take_while(|token| token.is_comment()) + .any(|token| dprint_core::formatting::ir_helpers::text_has_dprint_ignore(token.text, "dprint-ignore-file")) +} + +fn render_diagnostic(text: &str, span: std::ops::Range, message: &str) -> String { + let (start, end) = highlight_range(text, span); + dprint_core::formatting::utils::string_utils::format_diagnostic(Some((start, end)), message, text) +} - // ensure ends with newline - let text = if !text.ends_with('\n') { - let mut text = text; - text.push('\n'); - text +/// The range to underline beneath the offending line, which the diagnostic only draws when it is +/// within a single line and at least one character wide. +fn highlight_range(text: &str, span: std::ops::Range) -> (usize, usize) { + let start = span.start.min(text.len()); + let line_end = text[start..].find('\n').map(|i| start + i).unwrap_or(text.len()); + let end = span.end.min(line_end); + if end > start { + (start, end) } else { - text - }; + match text[start..].chars().next() { + Some(c) => (start, start + c.len_utf8()), + None => ( + text[..start] + .chars() + .next_back() + .map(|c| start - c.len_utf8()) + .unwrap_or(start), + start, + ), + } + } +} - // newline - let text = if resolve_new_line_kind(&text, config.new_line_kind) == "\n" { - text.replace("\r\n", "\n") +fn config_to_print_options(text: &str, config: &Configuration) -> PrintOptions { + PrintOptions { + indent_width: config.indent_width, + max_width: config.line_width, + use_tabs: config.use_tabs, + new_line_text: resolve_new_line_kind(text, config.new_line_kind), + } +} + +// ---- verification ---- + +/// Checks that the formatted text holds the same tokens as the input, in the same order, so that +/// formatting only ever changes the whitespace between them. The exceptions are the casing of +/// keywords when the `casing` option changes it, the trailing whitespace of line comments, and the +/// code in a routine's body, which is compared the same way. +pub(crate) fn verify(original: &[Token], formatted: &str, config: &Configuration) -> Result<(), FormatError> { + let formatted_tokens = lexer::lex(formatted, config.dialect).map_err(|_| FormatError::WouldChangeCode)?; + if tokens_equivalent(original, &formatted_tokens, config) { + Ok(()) } else { - // lazy - text.replace("\r\n", "\n").replace("\n", "\r\n") + Err(FormatError::WouldChangeCode) + } +} + +fn tokens_equivalent(a: &[Token], b: &[Token], config: &Configuration) -> bool { + // a template tag on a line of its own is like a comment to the formatter, but it may hold code + // (ex. `{% if x %}`), so it's compared in its place among the code + let is_comment = |t: &&Token| matches!(t.kind, TokenKind::LineComment | TokenKind::BlockComment); + let mut a_code = a.iter().filter(|t| !is_comment(t)); + let mut b_code = b.iter().filter(|t| !is_comment(t)); + let mut previous_is_string = false; + loop { + match (a_code.next(), b_code.next()) { + (None, None) => break, + (Some(a), Some(b)) if token_equivalent(a, b, config) => { + // PostgreSQL only joins strings that are separated by a line break + if previous_is_string && a.kind == TokenKind::String && a.starts_line() != b.starts_line() { + return false; + } + previous_is_string = a.kind == TokenKind::String; + } + _ => return false, + } + } + let a_comments = a.iter().filter(is_comment).map(comment_text); + let b_comments = b.iter().filter(is_comment).map(comment_text); + a_comments.eq(b_comments) +} + +fn comment_text<'a>(token: &'a Token) -> std::borrow::Cow<'a, str> { + let text = match token.kind { + TokenKind::LineComment | TokenKind::TemplateLine | TokenKind::Template => token.text.trim_end_matches([' ', '\t']), + // a SQLite comment may run to the end of the file, which the formatter ends with a newline + TokenKind::BlockComment if !token.text.ends_with("*/") => token.text.trim_end_matches(lexer::is_space), + _ => token.text, }; + normalize_newlines(text) +} + +fn token_equivalent(a: &Token, b: &Token, config: &Configuration) -> bool { + // whether a template tag is on a line of its own only changes how it's formatted + let is_template = |t: &Token| matches!(t.kind, TokenKind::Template | TokenKind::TemplateLine); + if is_template(a) && is_template(b) { + return comment_text(a) == comment_text(b); + } + if a.kind != b.kind { + return false; + } + if normalize_newlines(a.text) == normalize_newlines(b.text) { + return true; + } + match a.kind { + TokenKind::Word => match config.uppercase { + UppercaseKind::Preserve => false, + UppercaseKind::Upper => b.text == a.text.to_ascii_uppercase(), + UppercaseKind::Lower => b.text == a.text.to_ascii_lowercase(), + }, + TokenKind::Directive | TokenKind::BatchSeparator => { + a.text.trim_end_matches(lexer::is_space) == b.text.trim_end_matches(lexer::is_space) + } + TokenKind::DollarString => dollar_string_bodies_equivalent(a.text, b.text, config), + _ => false, + } +} + +fn dollar_string_bodies_equivalent(a: &str, b: &str, config: &Configuration) -> bool { + let tag_len = |text: &str| text[1..].find('$').map(|i| i + 2).unwrap_or(text.len()); + let (a_tag_len, b_tag_len) = (tag_len(a), tag_len(b)); + if a[..a_tag_len] != b[..b_tag_len] || a.len() < a_tag_len * 2 || b.len() < b_tag_len * 2 { + return false; + } + let a_body = &a[a_tag_len..a.len() - a_tag_len]; + let b_body = &b[b_tag_len..b.len() - b_tag_len]; + match (lexer::lex(a_body, config.dialect), lexer::lex(b_body, config.dialect)) { + (Ok(a_tokens), Ok(b_tokens)) => tokens_equivalent(&a_tokens, &b_tokens, config), + _ => false, + } +} - if text == input_text { - None +fn normalize_newlines(text: &str) -> std::borrow::Cow<'_, str> { + if text.contains('\r') { + std::borrow::Cow::Owned(text.replace("\r\n", "\n")) } else { - Some(text) + std::borrow::Cow::Borrowed(text) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::configuration::ConfigurationBuilder; + + #[test] + fn strips_bom() { + let config = ConfigurationBuilder::new().build(); + let file_text = format_text(Path::new("file.sql"), "\u{FEFF}SELECT 1;\n", &config).unwrap(); + assert_eq!(file_text.unwrap(), "SELECT 1;\n"); + } + + #[test] + fn verification_rejects_changed_tokens() { + let config = ConfigurationBuilder::new().build(); + let tokens = lexer::lex("SELECT a FROM t", Dialect::Generic).unwrap(); + assert!(verify(&tokens, "SELECT a\nFROM t\n", &config).is_ok()); + assert!(verify(&tokens, "SELECT b FROM t", &config).is_err()); + assert!(verify(&tokens, "SELECT a FROM", &config).is_err()); + assert!(verify(&tokens, "select a from t", &config).is_err()); + let upper = ConfigurationBuilder::new().casing(UppercaseKind::Upper).build(); + let lower_tokens = lexer::lex("select a from t", Dialect::Generic).unwrap(); + assert!(verify(&lower_tokens, "SELECT a FROM t", &upper).is_ok()); + // a word's case only changes to the configured one + assert!(verify(&lower_tokens, "Select a from t", &upper).is_err()); + assert!(verify(&tokens, "select a from t", &upper).is_err()); + } + + #[test] + fn verification_compares_comments() { + let config = ConfigurationBuilder::new().build(); + let tokens = lexer::lex("SELECT a -- x \nFROM t", Dialect::Generic).unwrap(); + assert!(verify(&tokens, "SELECT a -- x\nFROM t", &config).is_ok()); + assert!(verify(&tokens, "SELECT a -- y\nFROM t", &config).is_err()); + assert!(verify(&tokens, "SELECT a\nFROM t", &config).is_err()); + } + + #[test] + fn verification_compares_routine_bodies_as_code() { + let config = ConfigurationBuilder::new().dialect(Dialect::PostgreSql).build(); + let tokens = lexer::lex("DO $$ BEGIN NULL; END $$", Dialect::PostgreSql).unwrap(); + assert!(verify(&tokens, "DO $$\nBEGIN\n NULL;\nEND\n$$", &config).is_ok()); + assert!(verify(&tokens, "DO $$ BEGIN NULL END $$", &config).is_err()); + assert!(verify(&tokens, "DO $x$ BEGIN NULL; END $x$", &config).is_err()); } } diff --git a/src/generation/context.rs b/src/generation/context.rs new file mode 100644 index 0000000..c488109 --- /dev/null +++ b/src/generation/context.rs @@ -0,0 +1,29 @@ +use std::collections::HashSet; + +use crate::configuration::Configuration; + +pub struct Context<'a> { + pub config: &'a Configuration, + /// The text being formatted, which an ignored statement is written from. + pub text: &'a str, + /// Where the dollar-quoted strings start that hold the code of a routine (ex. the body of a + /// PL/pgSQL function), which is formatted as SQL too. + pub code_bodies: HashSet, + /// Where the name of a table starts that the clause being written begins with (ex. the `view` + /// of `FROM view`). + pub table_name_start: Option, + /// How many values separated onto lines when they don't fit on one are within each other. + pub separated_values_depth: u32, +} + +impl<'a> Context<'a> { + pub fn new(config: &'a Configuration, text: &'a str) -> Self { + Self { + config, + text, + code_bodies: HashSet::new(), + table_name_start: None, + separated_values_depth: 0, + } + } +} diff --git a/src/generation/generate.rs b/src/generation/generate.rs new file mode 100644 index 0000000..cc482f2 --- /dev/null +++ b/src/generation/generate.rs @@ -0,0 +1,2569 @@ +use std::rc::Rc; + +use dprint_core::formatting::conditions::*; +use dprint_core::formatting::ir_helpers; +use dprint_core::formatting::ir_helpers::GenSeparatedValuesOptions; +use dprint_core::formatting::ir_helpers::GenSeparatedValuesResult; +use dprint_core::formatting::ir_helpers::GeneratedValue; +use dprint_core::formatting::ir_helpers::LinesSpan; +use dprint_core::formatting::ir_helpers::MultiLineOptions; +use dprint_core::formatting::ir_helpers::SingleLineOptions; +use dprint_core::formatting::*; + +use super::Context; +use crate::ast::*; +use crate::configuration::UppercaseKind; +use crate::keywords; +use crate::lexer::Token; +use crate::lexer::TokenKind; + +pub fn generate(list: &StatementList, context: &mut Context) -> PrintItems { + let mut items = gen_statement_list(&list.items, context, true); + items.push_condition(if_true( + "endOfFileNewLine", + Rc::new(|context| Some(context.writer_info.column_number > 0 || context.writer_info.line_number > 0)), + Signal::NewLine.into(), + )); + items +} + +/// The items of the top level statement list without the newline at the end of the file, which +/// formatting a range uses. +pub fn generate_items(items: &[Item], context: &mut Context) -> PrintItems { + gen_statement_list(items, context, true) +} + +// ---- statement lists ---- + +pub(crate) fn gen_statement_list(items: &[Item], context: &mut Context, is_top_level: bool) -> PrintItems { + let mut result = PrintItems::new(); + for (i, item) in items.iter().enumerate() { + if i > 0 && is_glued_to_empty_statement(&items[i - 1], item) { + // T-SQL's `;WITH`, which guards against the statement before it missing its `;` + } else if i > 0 { + let mut newlines = if item.blank_line_before { 2 } else { 1 }; + // `linesBetweenQueries` separates a statement from whatever comes after it at the top level, + // which leaves a comment introducing the next statement right above it + if is_top_level + && matches!(items[i - 1].kind, ItemKind::Statement(_)) + && !matches!(item.kind, ItemKind::Command(..)) + { + newlines = newlines.max(usize::from(context.config.lines_between_queries.max(1))); + } + for _ in 0..newlines { + result.push_signal(Signal::NewLine); + } + } + result.extend(gen_item(item, context)); + } + result +} + +/// Whether `item` directly follows a `;` that's a statement of its own, with nothing between them. +fn is_glued_to_empty_statement(previous: &Item, item: &Item) -> bool { + let is_empty_statement = matches!(&previous.kind, ItemKind::Statement(statement) if statement.elements.is_empty() && statement.trailing_comments.is_empty() && !statement.ignored); + is_empty_statement + && matches!(&item.kind, ItemKind::Statement(statement) if !statement.ignored) + && item.first_token().is_some_and(|token| !token.space_before) +} + +fn gen_item(item: &Item, context: &mut Context) -> PrintItems { + match &item.kind { + ItemKind::Comment(token) => gen_comment(token), + ItemKind::Command(token, comments) => { + let mut items = gen_raw_text(token.text.trim_end_matches(crate::lexer::is_space)); + for comment in comments { + items.extend(gen_trailing_comment(comment)); + } + items + } + ItemKind::Statement(statement) => gen_statement(statement, context), + } +} + +/// The statements of a block's body: a comment on the line of whatever opened the body stays on +/// that line, and the rest are indented on the lines below. +fn gen_body(list: &StatementList, context: &mut Context) -> PrintItems { + let mut items = PrintItems::new(); + let mut rest = &list.items[..]; + while let Some(first) = rest.first() { + match &first.kind { + ItemKind::Comment(token) if token.newlines_before == 0 => { + items.extend(gen_trailing_comment(token)); + rest = &rest[1..]; + } + _ => break, + } + } + if !rest.is_empty() { + items.push_signal(Signal::NewLine); + items.extend(ir_helpers::with_indent(gen_statement_list(rest, context, false))); + } + items +} + +// ---- statements ---- + +fn gen_statement(statement: &Statement, context: &mut Context) -> PrintItems { + if statement.ignored { + return gen_ignored_statement(statement, context); + } + collect_code_bodies(statement, context); + + let mut items = gen_statement_elements(&statement.elements, context); + if let Some(terminator) = statement.terminator { + if terminator.kind == TokenKind::Delimiter && terminator.space_before && !statement.elements.is_empty() { + items.extend(space_if_not_start_of_line()); + } + items.extend(ir_helpers::gen_from_string(terminator.text)); + } + for comment in &statement.trailing_comments { + items.extend(gen_trailing_comment(comment)); + } + items +} + +/// A statement after a `dprint-ignore` comment, which is written exactly as it is. +fn gen_ignored_statement(statement: &Statement, context: &mut Context) -> PrintItems { + match (statement.first_token(), statement.last_token()) { + (Some(first), Some(last)) => gen_raw_text(&context.text[first.start..last.end()]), + _ => PrintItems::new(), + } +} + +/// Marks the dollar-quoted strings in the statement that hold PL/pgSQL or SQL code (ex. the body +/// of a function), which are formatted too. +fn collect_code_bodies(statement: &Statement, context: &mut Context) { + let elements = &statement.elements; + let mut language = None; + for (i, element) in elements.iter().enumerate() { + if element.is_word("LANGUAGE") + && let Some(value) = elements.get(i + 1).and_then(|e| e.as_token()) + { + language = Some(value.text.trim_matches('\'')); + } + } + let is_do = elements + .iter() + .find(|e| !e.is_comment()) + .is_some_and(|e| e.is_word("DO")); + let is_code = match language { + Some(language) => ["plpgsql", "sql"].iter().any(|l| l.eq_ignore_ascii_case(language)), + None => is_do, + }; + if is_code { + for element in elements { + if let Some(token) = element.as_token().filter(|token| token.kind == TokenKind::DollarString) { + context.code_bodies.insert(token.start); + } + } + } +} + +/// A statement's elements, which are runs of clauses with blocks of procedural code between them. +fn gen_statement_elements(elements: &[Element], context: &mut Context) -> PrintItems { + let mut items = PrintItems::new(); + let mut run_start = 0; + let mut has_written = false; + let mut previous_was_inline_label = false; + + let separate = |items: &mut PrintItems, has_written: &mut bool, previous_was_inline_label: bool| { + if *has_written { + if previous_was_inline_label { + items.push_space(); + } else { + items.push_signal(Signal::NewLine); + } + } + *has_written = true; + }; + + for (i, element) in elements.iter().enumerate() { + let is_segment = element.is_procedural() || matches!(element, Element::Label(_)); + if !is_segment { + continue; + } + if run_start < i { + separate(&mut items, &mut has_written, previous_was_inline_label); + let declarations_follow = matches!(element, Element::DeclareSection(section) if section.keyword.is_none()); + items.extend(gen_run(&elements[run_start..i], declarations_follow, context)); + previous_was_inline_label = false; + } + separate(&mut items, &mut has_written, previous_was_inline_label); + items.extend(gen_procedural(element, context)); + previous_was_inline_label = matches!(element, Element::Label(label) if !label.own_line); + run_start = i + 1; + } + if run_start < elements.len() { + separate(&mut items, &mut has_written, previous_was_inline_label); + items.extend(gen_run(&elements[run_start..], false, context)); + } + items +} + +/// A run of elements without procedural code, which is laid out as clauses each starting a line. +fn gen_run(elements: &[Element], declarations_follow: bool, context: &mut Context) -> PrintItems { + let clauses = split_clauses_ex(elements, declarations_follow); + // a query that a cursor is declared or opened for, or that's returned, is indented beneath the + // statement's head when it doesn't fit on its line + let first_word = elements.iter().find(|e| !e.is_comment()); + let embeds_query = first_word.is_some_and(|e| ["CURSOR", "DECLARE", "OPEN", "RETURN"].iter().any(|w| e.is_word(w))); + let query_start = if embeds_query { + clauses + .iter() + .position(|clause| clause.kind == ClauseKind::With || clause.keyword.first().is_some_and(|e| e.is_word("SELECT"))) + .filter(|index| *index > 0) + } else { + None + }; + let head_count = query_start.unwrap_or(clauses.len()); + let mut items = PrintItems::new(); + for (i, clause) in clauses[..head_count].iter().enumerate() { + if i > 0 && clause.kind != ClauseKind::InlineList { + items.push_signal(Signal::NewLine); + } + items.extend(gen_clause(clause, context)); + } + if query_start.is_some() { + let query = &clauses[head_count..]; + let force = query.iter().any(|clause| clause.is_known_multi_line()); + items.extend(gen_clauses_fit(query, force, ClausesLayout::AfterHead, context)); + } + items +} + +// ---- procedural code ---- + +fn gen_procedural(element: &Element, context: &mut Context) -> PrintItems { + match element { + Element::Block(block) => gen_block(block, context), + Element::DeclareSection(section) => gen_declare_section(section, context), + Element::If(block) => gen_if(block, context), + Element::Conditional(conditional) => gen_conditional(conditional, context), + Element::Loop(block) => gen_loop(block, context), + Element::CaseStatement(statement) => gen_case_statement(statement, context), + Element::Label(label) => gen_label(label), + _ => gen_fill(std::slice::from_ref(element), context), + } +} + +fn gen_block(block: &Block, context: &mut Context) -> PrintItems { + let mut items = gen_keywords(&block.begin, context); + items.extend(gen_body(&block.body, context)); + if let Some(exception) = &block.exception { + items.push_signal(Signal::NewLine); + items.extend(gen_keyword(exception.keyword, context)); + let mut handlers = PrintItems::new(); + for comment in &exception.leading_comments { + handlers.push_signal(Signal::NewLine); + handlers.extend(gen_comment(comment)); + } + for handler in &exception.handlers { + handlers.push_signal(Signal::NewLine); + handlers.extend(gen_handler(handler, context)); + } + items.extend(ir_helpers::with_indent(handlers)); + } + items.push_signal(Signal::NewLine); + items.extend(gen_end(&block.end, context)); + items +} + +/// `WHEN condition THEN` followed by the statements of an exception handler or a procedural +/// `CASE` branch. +fn gen_handler(handler: &Handler, context: &mut Context) -> PrintItems { + let mut items = gen_keyword(handler.when, context); + if !handler.condition.is_empty() { + items.push_space(); + items.extend(gen_fill(&handler.condition, context)); + } + items.extend(space_if_not_start_of_line()); + items.extend(gen_keyword(handler.then, context)); + items.extend(gen_body(&handler.body, context)); + items +} + +/// `END` and the words after it, of which only the ones saying what ends are keywords (ex. the +/// `IF` of `END IF`, but not the name of a procedure). +fn gen_end(tokens: &[Tok], context: &mut Context) -> PrintItems { + let mut items = PrintItems::new(); + for (i, token) in tokens.iter().enumerate() { + if i > 0 { + items.push_space(); + } + let is_keyword = + i == 0 || keywords::contains(keywords::RESERVED, token.text) || token.is_word("TRY") || token.is_word("CATCH"); + items.extend(gen_word(token, is_keyword, context)); + } + items +} + +fn gen_declare_section(section: &DeclareSection, context: &mut Context) -> PrintItems { + match section.keyword { + Some(keyword) => { + let mut items = gen_keyword(keyword, context); + items.extend(gen_body(§ion.declarations, context)); + items + } + // the declarations of an Oracle routine follow its `IS`, which ends the line before them + None => ir_helpers::with_indent(gen_statement_list(§ion.declarations.items, context, false)), + } +} + +fn gen_if(block: &IfBlock, context: &mut Context) -> PrintItems { + let mut items = PrintItems::new(); + for (i, branch) in block.branches.iter().enumerate() { + if i > 0 { + items.push_signal(Signal::NewLine); + } + items.extend(gen_keyword(branch.keyword, context)); + items.push_space(); + items.extend(gen_conditions_hanging(&branch.condition, context)); + items.extend(space_if_not_start_of_line()); + items.extend(gen_keyword(branch.then, context)); + items.extend(gen_body(&branch.body, context)); + } + if let Some(else_branch) = &block.else_branch { + items.push_signal(Signal::NewLine); + items.extend(gen_keyword(else_branch.keyword, context)); + items.extend(gen_body(&else_branch.body, context)); + } + items.push_signal(Signal::NewLine); + items.extend(gen_end(&block.end, context)); + items +} + +/// T-SQL's `IF` and `WHILE`, whose body is a single statement. A block is written beneath the +/// condition at the same indentation, like its `BEGIN` and `END` always are, and any other +/// statement is indented beneath it. +fn gen_conditional(conditional: &Conditional, context: &mut Context) -> PrintItems { + let mut items = gen_keyword(conditional.keyword, context); + items.push_space(); + // the lines of a condition that doesn't fit are indented further than the statement beneath it + items.extend(ir_helpers::with_indent(gen_conditions_hanging( + &conditional.condition, + context, + ))); + items.push_signal(Signal::NewLine); + items.extend(gen_conditional_body(&conditional.body, context)); + for comment in &conditional.else_comments { + items.push_signal(Signal::NewLine); + items.extend(gen_comment(comment)); + } + if let Some((keyword, statement)) = &conditional.else_branch { + items.push_signal(Signal::NewLine); + items.extend(gen_keyword(keyword, context)); + let is_else_if = matches!(statement.elements.first(), Some(Element::Conditional(c)) if c.keyword.is_word("IF")); + if is_else_if { + items.push_space(); + items.extend(gen_statement(statement, context)); + } else { + items.push_signal(Signal::NewLine); + items.extend(gen_conditional_body(statement, context)); + } + } + items +} + +fn gen_conditional_body(statement: &Statement, context: &mut Context) -> PrintItems { + let is_block = statement + .elements + .iter() + .find(|e| !e.is_comment()) + .is_some_and(|e| matches!(e, Element::Block(_))); + let items = gen_statement(statement, context); + if is_block { + items + } else { + ir_helpers::with_indent(items) + } +} + +fn gen_loop(block: &Loop, context: &mut Context) -> PrintItems { + let mut items = gen_loop_header(&block.header, context); + items.extend(gen_body(&block.body, context)); + if let Some((keyword, condition)) = &block.until { + items.push_signal(Signal::NewLine); + items.extend(gen_keyword(keyword, context)); + items.push_space(); + items.extend(gen_conditions_hanging(condition, context)); + } + items.push_signal(Signal::NewLine); + items.extend(gen_end(&block.end, context)); + items +} + +/// The header of a loop up to and including its `LOOP` or `DO`. A query to loop over (ex. +/// `FOR row IN SELECT ..`) stays on the line when it fits, and otherwise is indented on the +/// lines between the `IN` and the `LOOP`. +fn gen_loop_header(header: &[Element], context: &mut Context) -> PrintItems { + let query_start = header + .iter() + .enumerate() + .skip(1) + .find(|(i, element)| element.is_word("SELECT") || (element.is_word("WITH") && *i + 1 < header.len())) + .map(|(i, _)| i); + let Some(query_start) = query_start.filter(|start| *start < header.len() - 1) else { + return gen_fill(header, context); + }; + let last = header.len() - 1; + let mut items = gen_fill(&header[..query_start], context); + let clauses = split_clauses(&header[query_start..last]); + let force = clauses.iter().any(|clause| clause.is_known_multi_line()); + items.extend(gen_clauses_fit(&clauses, force, ClausesLayout::BetweenWords, context)); + items.extend(gen_fill(&header[last..], context)); + items +} + +fn gen_case_statement(statement: &CaseStatement, context: &mut Context) -> PrintItems { + let mut items = gen_keyword(statement.case_keyword, context); + if !statement.operand.is_empty() { + items.push_space(); + items.extend(gen_fill(&statement.operand, context)); + } + let mut branches = PrintItems::new(); + for branch in &statement.branches { + branches.push_signal(Signal::NewLine); + branches.extend(gen_handler(branch, context)); + } + if let Some(else_branch) = &statement.else_branch { + branches.push_signal(Signal::NewLine); + branches.extend(gen_keyword(else_branch.keyword, context)); + branches.extend(gen_body(&else_branch.body, context)); + } + items.extend(ir_helpers::with_indent(branches)); + items.push_signal(Signal::NewLine); + items.extend(gen_end(&statement.end, context)); + items +} + +fn gen_label(label: &Label) -> PrintItems { + let text: String = label.tokens.iter().map(|token| token.text).collect(); + ir_helpers::gen_from_string(&text) +} + +// ---- clauses ---- + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ClauseKind { + /// What comes before the first clause keyword, written as it flows. + Head, + /// A keyword followed by whatever is after it, written as it flows (ex. `LIMIT 10`). + Fill, + /// A keyword followed by a comma separated list, which is written on the keyword's line when + /// it fits and otherwise with each item on a line of its own (ex. `SELECT a, b`). + List, + /// A comma separated list that continues the line of the clause before it (ex. the parameters + /// of a T-SQL procedure). + InlineList, + /// A keyword followed by conditions joined by `AND` and `OR`, which go on lines of their own + /// when they don't fit (ex. `WHERE a = 1 AND b = 2`). + Condition, + /// `FROM`, its tables and its joins. + From, + /// `WITH` and its common table expressions. + With, + /// `UNION`, `INTERSECT`, `EXCEPT` or `MINUS` on a line of its own. + SetOperator, + /// `WHEN [NOT] MATCHED .. THEN` and the action of a `MERGE`. + MergeWhen, + /// A keyword followed by what's after it written exactly as it is, since the whitespace in it + /// means something (ex. the regular expression of a `MATCH_RECOGNIZE`'s `PATTERN`). + Verbatim, +} + +struct Clause<'e, 'a> { + kind: ClauseKind, + /// Comments on lines of their own above the clause. + leading_comments: Vec>, + keyword: &'e [Element<'a>], + body: &'e [Element<'a>], +} + +impl Clause<'_, '_> { + fn is_known_multi_line(&self) -> bool { + !self.leading_comments.is_empty() || is_known_multi_line(self.keyword) || is_known_multi_line(self.body) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Mode { + Query, + Insert, + Update, + Delete, + Merge, + /// Creates a procedure, function, trigger or package. + Routine, + AlterTable, + Declare, + Exec, + /// The clauses of a window (ex. `OVER (PARTITION BY a ORDER BY b)`). + Window, + Other, +} + +/// Splits a run of elements into clauses, each of which starts a line when the run is written over +/// several lines. +fn split_clauses<'e, 'a>(elements: &'e [Element<'a>]) -> Vec> { + split_clauses_ex(elements, false) +} + +/// Like `split_clauses`, where `declarations_follow` says the declarations of an Oracle routine +/// come next. +fn split_clauses_ex<'e, 'a>(elements: &'e [Element<'a>], declarations_follow: bool) -> Vec> { + let mode = detect_mode(elements); + let mut scan = ClauseScan { + mode, + query: mode == Mode::Query, + select_list: false, + insert_body: false, + set_seen: false, + using_seen: false, + conflict_seen: false, + body_started: false, + params_seen: false, + referencing_seen: false, + query_started: false, + declarations_follow, + last_kind: None, + returns_seen: false, + dml_start: elements.iter().position(|e| !e.is_comment()).unwrap_or(0), + }; + // (start, keyword length, kind) + let mut starts: Vec<(usize, usize, ClauseKind)> = vec![(0, 0, ClauseKind::Head)]; + if let Some(start) = scan.special_head_end(elements) { + starts.push((start, 0, ClauseKind::InlineList)); + } + let mut i = 0; + while i < elements.len() { + if starts + .last() + .is_some_and(|(start, _, kind)| *kind == ClauseKind::InlineList && *start == i) + { + // the list runs to the end, apart from a routine's clauses after its parameters + if scan.mode != Mode::Routine { + break; + } + } + if let Some((kind, len)) = scan.match_clause(elements, i) { + if i == 0 { + starts[0] = (0, len, kind); + } else { + starts.push((i, len, kind)); + } + i += len.max(1); + continue; + } + if let Some(start) = scan.match_params(elements, i) { + starts.push((start, 0, ClauseKind::InlineList)); + } + i += 1; + } + + let mut clauses: Vec = Vec::with_capacity(starts.len()); + let mut pending: Vec> = Vec::new(); + for (index, (start, keyword_len, kind)) in starts.iter().enumerate() { + let end = starts + .get(index + 1) + .map(|(start, _, _)| *start) + .unwrap_or(elements.len()); + let keyword = &elements[*start..start + keyword_len]; + let mut body = &elements[start + keyword_len..end]; + let leading_comments = std::mem::take(&mut pending); + // comments on lines of their own at the end of a clause introduce the next one + if let Some((_, _, next_kind)) = starts.get(index + 1) + && *kind != ClauseKind::InlineList + && *next_kind != ClauseKind::InlineList + { + let split = own_line_comments_start(body); + pending = body[split..].iter().filter_map(|e| e.as_token()).collect(); + body = &body[..split]; + } + clauses.push(Clause { + kind: *kind, + leading_comments, + keyword, + body, + }); + } + clauses.retain(|clause| !(clause.keyword.is_empty() && clause.body.is_empty() && clause.leading_comments.is_empty())); + clauses +} + +fn detect_mode(elements: &[Element]) -> Mode { + let mut significant = elements.iter().filter(|e| !e.is_comment()); + let Some(first) = significant.next() else { + return Mode::Other; + }; + if let Element::Group(group) = first { + return if is_query_start(&group.elements) { + Mode::Query + } else { + Mode::Other + }; + } + let Some(token) = first.as_token().filter(|token| token.kind == TokenKind::Word) else { + return Mode::Other; + }; + let is = |word: &str| token.is_word(word); + let second = significant.next(); + if is("SELECT") || is("WITH") || is("VALUES") || is("PERFORM") { + Mode::Query + } else if is("INSERT") || is("UPSERT") || (is("REPLACE") && !matches!(second, Some(Element::Group(_)))) { + Mode::Insert + } else if is("UPDATE") { + Mode::Update + } else if is("DELETE") { + Mode::Delete + } else if is("MERGE") { + Mode::Merge + } else if is("PARTITION") || is("ORDER") || is("ROWS") || is("RANGE") || is("GROUPS") || is("MEASURES") { + Mode::Window + } else if (is("CREATE") || is("ALTER")) + && elements + .iter() + .filter(|e| !e.is_comment()) + .take(8) + .filter_map(|e| e.as_token().filter(|t| t.kind == TokenKind::Word)) + .find_map(|t| keywords::object_kind_is_routine(t.text)) + == Some(true) + { + Mode::Routine + } else if is("ALTER") && second.is_some_and(|e| e.is_word("TABLE")) { + Mode::AlterTable + } else if is("DECLARE") + && !elements + .iter() + .any(|e| e.is_word("CURSOR") || e.is_word("HANDLER") || e.is_word("CONDITION")) + { + Mode::Declare + } else if is("EXEC") || is("EXECUTE") { + Mode::Exec + } else if is("PROCEDURE") + || is("FUNCTION") + || (["MEMBER", "STATIC", "CONSTRUCTOR", "MAP", "ORDER", "OVERRIDING", "FINAL"] + .iter() + .any(|w| is(w)) + && elements + .iter() + .filter(|e| !e.is_comment()) + .take(3) + .any(|e| e.is_word("FUNCTION") || e.is_word("PROCEDURE"))) + { + // a routine in an Oracle package or type body + Mode::Routine + } else { + Mode::Other + } +} + +struct ClauseScan { + mode: Mode, + /// Whether the clauses of a query (ex. `FROM` and `WHERE`) are recognized. + query: bool, + /// Whether the last clause is the list of a `SELECT`, which an `INTO` may follow. + select_list: bool, + /// Whether what's inserted has started (ex. its `VALUES` or `SELECT`). + insert_body: bool, + set_seen: bool, + using_seen: bool, + conflict_seen: bool, + /// Whether a routine's `AS` or `IS` has been seen. + body_started: bool, + /// Whether a T-SQL routine's parameters have been seen. + params_seen: bool, + /// Whether a trigger's `REFERENCING` has been seen, which names its old and new rows. + referencing_seen: bool, + /// Whether a `SELECT` or `VALUES` has started a query. + query_started: bool, + /// Whether the declarations of an Oracle routine follow the elements. + declarations_follow: bool, + /// The kind of the last clause that was matched. + last_kind: Option, + /// Whether a routine's `RETURNS` has been seen. + returns_seen: bool, + /// Where the statement's `INSERT`, `UPDATE`, `DELETE` or `MERGE` is, which follows its common + /// table expressions when it has any. + dml_start: usize, +} + +impl ClauseScan { + /// Where the comma separated list starts in a statement whose head is followed by one, which + /// continues the line of the head: the actions of an `ALTER TABLE` and the arguments of an + /// `EXEC`. + fn special_head_end(&mut self, elements: &[Element]) -> Option { + let mut i = elements.iter().position(|e| !e.is_comment())?; + match self.mode { + Mode::AlterTable => { + i += 2; + while word_at(elements, i) + .is_some_and(|t| t.is_word("IF") || t.is_word("NOT") || t.is_word("EXISTS") || t.is_word("ONLY")) + { + i += 1; + } + i = skip_name(elements, i)?; + if elements + .get(i) + .is_some_and(|e| e.as_token().is_some_and(|t| t.is_operator("*"))) + { + i += 1; + } + } + Mode::Exec => { + i += 1; + // `EXEC @result = name` + if elements.get(i).is_some_and(|e| e.is_kind(TokenKind::Variable)) + && elements + .get(i + 1) + .is_some_and(|e| e.as_token().is_some_and(|t| t.is_operator("="))) + { + i += 2; + } + i = skip_name(elements, i)?; + } + _ => return None, + } + let rest = elements.get(i..)?; + let starts_list = rest + .iter() + .find(|e| !e.is_comment()) + .is_some_and(|e| !matches!(e, Element::Group(_)) && !e.is_kind(TokenKind::Semicolon)); + starts_list.then_some(i) + } + + /// Where the parameters of a T-SQL routine start, which aren't in brackets. + fn match_params(&mut self, elements: &[Element], i: usize) -> Option { + if self.mode != Mode::Routine || self.params_seen || self.body_started || !elements[i].is_kind(TokenKind::Variable) + { + return None; + } + // the parameters follow the routine's name + let kind_index = elements[..i] + .iter() + .rposition(|e| e.is_word("PROCEDURE") || e.is_word("PROC") || e.is_word("FUNCTION"))?; + if skip_name(elements, kind_index + 1) != Some(i) { + return None; + } + self.params_seen = true; + Some(i) + } + + fn match_clause(&mut self, elements: &[Element], i: usize) -> Option<(ClauseKind, usize)> { + let token = word_at(elements, i)?; + let previous = prev_significant(elements, i); + // a word that's part of a qualified name, or an item of a list (ex. a column named `offset`) + if previous.is_some_and(|e| e.is_kind(TokenKind::Dot) || e.is_kind(TokenKind::Comma)) + || elements.get(i + 1).is_some_and(|e| e.is_kind(TokenKind::Dot)) + { + return None; + } + let prev_word = previous + .and_then(|e| e.as_token()) + .filter(|t| t.kind == TokenKind::Word); + let words = ClauseWords { + elements, + i, + token, + prev_word, + }; + let result = self.match_clause_words(&words); + if let Some((kind, _)) = result { + self.select_list = kind == ClauseKind::List && (token.is_word("SELECT") || token.is_word("PERFORM")); + self.last_kind = Some(kind); + } + result + } + + fn match_clause_words(&mut self, words: &ClauseWords) -> Option<(ClauseKind, usize)> { + use ClauseKind::*; + let elements = words.elements; + let i = words.i; + let is = |word: &str| words.token.is_word(word); + let next_is = |offset: usize, word: &str| words.next_is(offset, word); + let prev_is = |candidates: &[&str]| words.prev_is(candidates); + + if self.mode == Mode::Window { + if (is("PARTITION") || is("ORDER")) && next_is(1, "BY") { + return Some((List, 2)); + } + if is("ROWS") || is("RANGE") || is("GROUPS") { + return Some((Fill, 1)); + } + // the clauses of a `MATCH_RECOGNIZE` + if is("MEASURES") || is("DEFINE") || is("SUBSET") { + return Some((List, 1)); + } + if (is("ONE") || is("ALL")) && (next_is(1, "ROW") || next_is(1, "ROWS")) && next_is(2, "PER") { + return Some((Fill, 1)); + } + if is("AFTER") && next_is(1, "MATCH") { + return Some((Fill, 2)); + } + if is("PATTERN") { + return Some((Verbatim, 1)); + } + return None; + } + + // set operators, unless it's BigQuery's `SELECT * EXCEPT (column)` + if self.query && (is("UNION") || is("INTERSECT") || is("EXCEPT") || is("MINUS")) { + let mut len = 1; + if next_is(1, "ALL") || next_is(1, "DISTINCT") { + len += 1; + } + return is_query_start(&elements[i + len..]).then_some((SetOperator, len)); + } + + // the statement after common table expressions (ex. `WITH a AS (..) UPDATE t SET ..`) + if self.mode == Mode::Query && self.last_kind == Some(With) { + let mode = if is("INSERT") || is("UPSERT") || is("REPLACE") { + Some(Mode::Insert) + } else if is("UPDATE") { + Some(Mode::Update) + } else if is("DELETE") { + Some(Mode::Delete) + } else if is("MERGE") { + Some(Mode::Merge) + } else { + None + }; + if let Some(mode) = mode { + self.mode = mode; + self.query = false; + self.insert_body = false; + self.dml_start = i; + return Some((Fill, 1)); + } + } + + if is("SELECT") || (i == 0 && is("PERFORM")) { + // PostgreSQL's `ON CONFLICT .. DO SELECT`, which isn't a query + if prev_is(&["DO"]) && self.conflict_seen { + return None; + } + // a second query on the line of the first isn't one of its clauses, since once it starts a + // line the generic dialect reads it as a statement of its own + if self.query_started + && !words.token.starts_line() + && !prev_is(&["UNION", "ALL", "DISTINCT", "INTERSECT", "EXCEPT", "MINUS"]) + { + return None; + } + let allowed = match self.mode { + Mode::Other | Mode::Routine | Mode::Declare | Mode::Exec | Mode::AlterTable => { + i == 0 || self.query || prev_is(&["AS", "IS", "QUERY", "EXPLAIN", "ANALYZE", "VERBOSE", "RETURN", "FOR"]) + } + _ => true, + }; + if !allowed { + return None; + } + self.query = true; + self.insert_body = true; + self.query_started = true; + return Some((List, 1 + select_modifiers_len(elements, i + 1))); + } + if is("WITH") && is_cte_start(elements, i) { + let allowed = i == 0 + || (self.mode == Mode::Insert && !self.insert_body) + || prev_is(&["AS", "IS", "QUERY", "EXPLAIN", "ANALYZE", "VERBOSE"]); + if allowed { + self.query = true; + self.insert_body = true; + return Some((With, if next_is(1, "RECURSIVE") { 2 } else { 1 })); + } + } + if is("VALUES") { + let inserting = self.mode == Mode::Insert && !self.insert_body && !prev_is(&["DEFAULT"]); + // MySQL's `VALUES(column)` function, as opposed to the clause + let is_call = + matches!(elements.get(i + 1), Some(Element::Group(group)) if group.is_paren() && !group.open.space_before); + if inserting || (!is_call && (i == 0 || prev_is(&["AS"]))) { + self.query = true; + self.insert_body = true; + self.query_started = true; + return Some((List, 1)); + } + } + + let is_dml = matches!(self.mode, Mode::Insert | Mode::Update | Mode::Delete | Mode::Merge); + if is("INTO") && self.select_list { + return Some((Fill, 1)); + } + if is("BULK") && next_is(1, "COLLECT") && next_is(2, "INTO") && self.select_list { + return Some((Fill, 3)); + } + if is("FROM") && !prev_is(&["DISTINCT"]) && (self.query || matches!(self.mode, Mode::Update | Mode::Delete)) { + // `DELETE FROM` names the table that's deleted from + let names_target = self.mode == Mode::Delete + && !self.query + && elements[self.dml_start..i].iter().filter(|e| !e.is_comment()).all(|e| { + e.is_word("DELETE") + || e.is_word("TOP") + || e.is_word("LOW_PRIORITY") + || e.is_word("QUICK") + || e.is_word("IGNORE") + || matches!(e, Element::Group(_)) + || e.is_kind(TokenKind::Number) + }); + if !names_target { + return Some((From, 1)); + } + } + if is("WHERE") + && (self.query + || matches!(self.mode, Mode::Update | Mode::Delete) + || (self.mode == Mode::Insert && self.conflict_seen)) + { + return Some((Condition, 1)); + } + if self.query || matches!(self.mode, Mode::Update | Mode::Delete) { + if is("ORDER") && next_is(1, "BY") { + return Some((List, 2)); + } + if is("ORDER") && next_is(1, "SIBLINGS") && next_is(2, "BY") { + return Some((List, 3)); + } + if is("LIMIT") { + return Some((Fill, 1)); + } + } + if self.query { + if is("GROUP") && next_is(1, "BY") { + return Some((List, 2)); + } + if is("HAVING") || is("QUALIFY") { + return Some((Condition, 1)); + } + if is("WINDOW") && word_at(elements, i + 1).is_some() && next_is(2, "AS") { + return Some((List, 1)); + } + if is("OFFSET") && !prev_is(&["WITH"]) { + return Some((Fill, 1)); + } + if is("FETCH") && (next_is(1, "FIRST") || next_is(1, "NEXT")) { + return Some((Fill, 2)); + } + if is("FOR") + && ["UPDATE", "SHARE", "NO", "KEY", "XML", "JSON", "BROWSE", "READ"] + .iter() + .any(|w| next_is(1, w)) + { + return Some((Fill, 1)); + } + if is("LOCK") && next_is(1, "IN") { + return Some((Fill, 1)); + } + if is("OPTION") && matches!(elements.get(i + 1), Some(Element::Group(_))) { + return Some((Fill, 1)); + } + if (is("CONNECT") && next_is(1, "BY")) || (is("START") && next_is(1, "WITH")) { + return Some((Condition, 2)); + } + } + match self.mode { + Mode::Update if is("SET") && !self.set_seen => { + self.set_seen = true; + return Some((List, 1)); + } + Mode::Insert => { + if is("SET") && (!self.insert_body || (self.conflict_seen && prev_is(&["UPDATE"]))) { + self.insert_body = true; + return Some((List, 1)); + } + if is("ON") && next_is(1, "CONFLICT") { + self.conflict_seen = true; + return Some((Fill, 2)); + } + if is("ON") && next_is(1, "DUPLICATE") && next_is(2, "KEY") && next_is(3, "UPDATE") { + return Some((List, 4)); + } + } + Mode::Delete if is("USING") => return Some((Fill, 1)), + Mode::Merge => { + if is("USING") && !self.using_seen { + self.using_seen = true; + return Some((Fill, 1)); + } + if is("ON") && self.using_seen { + return Some((Condition, 1)); + } + if is("WHEN") && (next_is(1, "MATCHED") || (next_is(1, "NOT") && next_is(2, "MATCHED"))) { + return Some((MergeWhen, 1)); + } + } + Mode::Routine => { + if is("LANGUAGE") && word_or_string_at(elements, i + 1) { + return Some((Fill, 1)); + } + // the body of a SQL function in PostgreSQL and Databricks (ex. `RETURNS int RETURN a + b`), + // as opposed to the type that an Oracle function returns + if is("RETURN") && self.returns_seen && !self.body_started { + self.body_started = true; + return Some((Fill, 1)); + } + if !self.body_started { + if is("RETURNS") { + self.returns_seen = true; + return Some((Fill, 1)); + } + if is("WITH") && self.params_seen { + return Some((Fill, 1)); + } + // Oracle's `IS` ends the line of the header, as does an `AS` that its declarations follow + if is("IS") { + self.body_started = true; + return None; + } + if is("REFERENCING") { + self.referencing_seen = true; + } + // as opposed to `EXECUTE AS` or a trigger's `REFERENCING OLD TABLE AS name` + if is("AS") && !prev_is(&["EXECUTE"]) && !(self.referencing_seen && prev_is(&["TABLE", "ROW", "OLD", "NEW"])) + { + self.body_started = true; + if i + 1 == elements.len() && self.declarations_follow { + return None; + } + return Some((Fill, 1)); + } + } + } + Mode::Declare if i == 0 => return Some((List, 1)), + _ => {} + } + if is_dml && (is("RETURNING") || is("OUTPUT")) { + return Some((List, 1)); + } + None + } +} + +/// The word that may start a clause and what's around it. +struct ClauseWords<'e, 'a> { + elements: &'e [Element<'a>], + i: usize, + token: Tok<'a>, + prev_word: Option>, +} + +impl ClauseWords<'_, '_> { + fn next_is(&self, offset: usize, word: &str) -> bool { + word_at(self.elements, self.i + offset).is_some_and(|t| t.is_word(word)) + } + + fn prev_is(&self, words: &[&str]) -> bool { + self.prev_word.is_some_and(|t| words.iter().any(|w| t.is_word(w))) + } +} + +/// How many of the elements after a `SELECT` modify it (ex. `DISTINCT`, `TOP (10)`), which are +/// written as part of its keyword. +fn select_modifiers_len(elements: &[Element], start: usize) -> usize { + let mut i = start; + while let Some(token) = word_at(elements, i) { + if token.is_word("DISTINCT") { + i += 1; + if word_at(elements, i).is_some_and(|t| t.is_word("ON")) && matches!(elements.get(i + 1), Some(Element::Group(_))) + { + i += 2; + } + } else if token.is_word("TOP") { + i += 1; + if elements.get(i).is_some_and(|e| { + matches!(e, Element::Group(_)) || e.is_kind(TokenKind::Number) || e.is_kind(TokenKind::Variable) + }) { + i += 1; + } + if word_at(elements, i).is_some_and(|t| t.is_word("PERCENT")) { + i += 1; + } + if word_at(elements, i).is_some_and(|t| t.is_word("WITH")) + && word_at(elements, i + 1).is_some_and(|t| t.is_word("TIES")) + { + i += 2; + } + } else if token.is_word("AS") && word_at(elements, i + 1).is_some_and(|t| t.is_word("STRUCT") || t.is_word("VALUE")) + { + i += 2; + } else if [ + "ALL", + "DISTINCTROW", + "HIGH_PRIORITY", + "STRAIGHT_JOIN", + "SQL_SMALL_RESULT", + "SQL_BIG_RESULT", + "SQL_BUFFER_RESULT", + "SQL_CACHE", + "SQL_NO_CACHE", + "SQL_CALC_FOUND_ROWS", + ] + .iter() + .any(|w| token.is_word(w)) + { + i += 1; + } else { + break; + } + } + i - start +} + +/// Where a name made of parts separated by dots (ex. `schema.table`) that starts at `start` ends. +fn skip_name(elements: &[Element], start: usize) -> Option { + let is_part = |e: &Element| { + e.as_token().is_some_and(|t| { + matches!( + t.kind, + TokenKind::Word | TokenKind::QuotedIdent | TokenKind::Variable | TokenKind::Template + ) + }) + }; + if !elements.get(start).is_some_and(is_part) { + return None; + } + let mut i = start + 1; + while elements.get(i).is_some_and(|e| e.is_kind(TokenKind::Dot)) && elements.get(i + 1).is_some_and(is_part) { + i += 2; + } + Some(i) +} + +fn gen_clause(clause: &Clause, context: &mut Context) -> PrintItems { + let mut items = PrintItems::new(); + let first_token = clause.keyword.first().or(clause.body.first()).map(|e| e.first_token()); + items.extend(gen_leading_comments(&clause.leading_comments, first_token)); + let mut body = clause.body; + let mut keyword_comment_ends_line = false; + if !clause.keyword.is_empty() { + items.extend(gen_fill_ex(clause.keyword, context, true)); + // a comment on the keyword's line stays there + while let Some(token) = body + .first() + .and_then(|e| e.as_token()) + .filter(|t| t.is_comment() && t.newlines_before == 0) + { + items.extend(gen_trailing_comment(token)); + keyword_comment_ends_line |= token.is_line_comment(); + body = &body[1..]; + } + } + if body.is_empty() { + return items; + } + if clause + .keyword + .last() + .and_then(|e| e.as_token()) + .is_some_and(names_table) + { + context.table_name_start = body.iter().find(|e| !e.is_comment()).map(|e| e.first_token().start); + } + // a comment on a line of its own after the keyword starts the body indented beneath it + if !clause.keyword.is_empty() + && !keyword_comment_ends_line + && body[0].is_comment() + && body[0].first_token().newlines_before > 0 + && !matches!( + clause.kind, + ClauseKind::List | ClauseKind::InlineList | ClauseKind::From + ) + { + items.push_signal(Signal::NewLine); + items.extend(ir_helpers::with_indent(gen_clause_body( + clause.kind, + body, + false, + false, + context, + ))); + return items; + } + let needs_space = !clause.keyword.is_empty() && !keyword_comment_ends_line; + items.extend(gen_clause_body( + clause.kind, + body, + needs_space, + keyword_comment_ends_line, + context, + )); + items +} + +fn gen_clause_body( + kind: ClauseKind, + body: &[Element], + needs_space: bool, + keyword_comment_ends_line: bool, + context: &mut Context, +) -> PrintItems { + let mut items = PrintItems::new(); + match kind { + ClauseKind::Head => items.extend(gen_fill(body, context)), + ClauseKind::Fill | ClauseKind::Condition | ClauseKind::With | ClauseKind::MergeWhen => { + if needs_space { + items.push_space(); + } + items.extend(match kind { + ClauseKind::Fill => gen_fill(body, context), + ClauseKind::Condition => gen_conditions_hanging(body, context), + ClauseKind::With => gen_with(body, context), + _ => gen_merge_when(body, context), + }); + } + ClauseKind::List | ClauseKind::InlineList => items.extend(gen_list( + body, + ListStyle::AfterKeyword, + keyword_comment_ends_line, + context, + )), + ClauseKind::From => items.extend(gen_from(body, keyword_comment_ends_line, context)), + ClauseKind::Verbatim => { + if needs_space { + items.push_space(); + } + let start = body[0].first_token().start; + let end = body[body.len() - 1].last_token().end(); + items.extend(gen_raw_text(&context.text[start..end])); + } + ClauseKind::SetOperator => { + items.push_signal(Signal::NewLine); + items.extend(gen_fill(body, context)); + } + } + items +} + +/// Where clauses that are written on one line when they fit are, which decides how they're +/// written when they don't. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ClausesLayout { + /// In brackets (ex. a subquery), with the clauses indented on the lines between them. + InBrackets, + /// Between the words of a loop's header (ex. `FOR r IN .. LOOP`), with the clauses indented on + /// the lines between them. + BetweenWords, + /// After the head of a statement (ex. `CURSOR c IS ..`), with the clauses indented on the + /// lines below it. + AfterHead, +} + +/// Clauses that are written on one line when they fit and otherwise on a line each. +fn gen_clauses_fit(clauses: &[Clause], force: bool, layout: ClausesLayout, context: &mut Context) -> PrintItems { + let indent_width = context.config.indent_width; + gen_nested_separated_values( + context, + |context| { + clauses + .iter() + .map(|clause| GeneratedValue { + items: gen_clause(clause, context), + lines_span: None, + allow_inline_multi_line: false, + allow_inline_single_line: false, + is_known_multi_line: clause.is_known_multi_line(), + }) + .collect() + }, + GenSeparatedValuesOptions { + prefer_hanging: false, + force_use_new_lines: force, + allow_blank_lines: false, + indent_width, + single_line_options: match layout { + ClausesLayout::InBrackets => SingleLineOptions::separated_same_line(Signal::SpaceOrNewLine.into()), + ClausesLayout::BetweenWords => SingleLineOptions::surrounded_line(), + ClausesLayout::AfterHead => SingleLineOptions::separated_line_starting_with_space(), + }, + multi_line_options: match layout { + ClausesLayout::InBrackets | ClausesLayout::BetweenWords => MultiLineOptions::surround_newlines_indented(), + ClausesLayout::AfterHead => MultiLineOptions::new_line_start(), + }, + force_possible_newline_at_start: false, + }, + ) + .items +} + +// ---- lists ---- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ListStyle { + /// After a keyword (ex. `SELECT a, b`), with the items indented on the lines below it when they + /// don't fit. + AfterKeyword, + /// Within brackets, with the items indented on the lines between them when they don't fit. + InBrackets, +} + +struct ListItem<'e, 'a> { + /// Comments on lines of their own before the item. + leading_comments: Vec>, + elements: &'e [Element<'a>], + comma: Option>, + /// Comments on the item's last line. + trailing_comments: Vec>, + /// Comments on lines of their own after the last item. + after_comments: Vec>, + blank_line_before: bool, +} + +impl ListItem<'_, '_> { + fn has_trailing_line_comment(&self) -> bool { + self.trailing_comments.iter().any(|c| c.is_line_comment()) + } + + /// Whether the item is written over several lines however the list is written. A line comment + /// at the end of the last item of a list after a keyword doesn't count, since the line ends + /// there anyway. + fn is_known_multi_line(&self, is_last: bool, style: ListStyle) -> bool { + !self.leading_comments.is_empty() + || !self.after_comments.is_empty() + || (self.has_trailing_line_comment() && !(is_last && style == ListStyle::AfterKeyword)) + || is_known_multi_line(self.elements) + } +} + +/// Splits a comma separated list into its items, giving each the comments around it. +fn split_list<'e, 'a>(elements: &'e [Element<'a>]) -> Vec> { + let mut segments: Vec<(&'e [Element<'a>], Option>)> = Vec::new(); + let mut start = 0; + let angle_types = angle_bracket_types(elements); + for (i, element) in elements.iter().enumerate() { + if angle_types.iter().any(|range| range.contains(&i)) { + continue; + } + if let Some(token) = element.as_token().filter(|t| t.kind == TokenKind::Comma) { + segments.push((&elements[start..i], Some(token))); + start = i + 1; + } + } + segments.push((&elements[start..], None)); + + let mut items: Vec = Vec::with_capacity(segments.len()); + let mut carried: Vec = Vec::new(); + let count = segments.len(); + for (index, (segment, comma)) in segments.into_iter().enumerate() { + let mut segment = segment; + // line comments on the line of the previous item's comma belong to it + if let Some(previous) = items.last_mut() { + while let Some(token) = segment + .first() + .and_then(|e| e.as_token()) + .filter(|t| t.is_line_comment() && t.newlines_before == 0) + { + previous.trailing_comments.push(token); + segment = &segment[1..]; + } + } + let mut leading_comments = std::mem::take(&mut carried); + while let Some(token) = segment + .first() + .and_then(|e| e.as_token()) + .filter(|t| t.is_comment() && t.newlines_before > 0) + { + leading_comments.push(token); + segment = &segment[1..]; + } + // comments at the end: the line comments on the item's last line trail it, after its comma, + // and the comments on lines of their own introduce the next item + let comments_start = segment.len() + - segment + .iter() + .rev() + .take_while(|e| { + e.as_token() + .is_some_and(|t| t.is_line_comment() || (t.is_comment() && t.newlines_before > 0)) + }) + .count(); + let mut trailing_comments = Vec::new(); + let mut after_comments = Vec::new(); + for element in &segment[comments_start..] { + let token = element.as_token().unwrap(); + if token.newlines_before > 0 || !after_comments.is_empty() { + after_comments.push(token); + } else { + trailing_comments.push(token); + } + } + let content = &segment[..comments_start]; + if index + 1 < count { + carried = std::mem::take(&mut after_comments); + } + if content.is_empty() + && comma.is_none() + && leading_comments.is_empty() + && trailing_comments.is_empty() + && after_comments.is_empty() + { + // nothing after a trailing comma + continue; + } + let blank_line_before = leading_comments + .first() + .map(|c| c.newlines_before >= 2) + .or_else(|| content.first().map(|e| e.first_token().newlines_before >= 2)) + .unwrap_or(false); + items.push(ListItem { + leading_comments, + elements: content, + comma, + trailing_comments, + after_comments, + blank_line_before, + }); + } + if !carried.is_empty() + && let Some(last) = items.last_mut() + { + last.after_comments.extend(carried); + } + items +} + +fn gen_list(elements: &[Element], style: ListStyle, force: bool, context: &mut Context) -> PrintItems { + // a template tag may hold the commas around it (ex. `{% if x %}, b{% endif %}`), so a list with + // one on a line of its own or a statement tag against a comma is written as it flows rather than + // item by item + if has_template_around_commas(elements) { + let mut items = PrintItems::new(); + let mut body = PrintItems::new(); + body.push_signal(Signal::NewLine); + body.extend(gen_fill(elements, context)); + items.extend(ir_helpers::with_indent(body)); + if style == ListStyle::InBrackets { + items.push_signal(Signal::NewLine); + } + return items; + } + let items = split_list(elements); + if items.is_empty() { + return PrintItems::new(); + } + let count = items.len(); + let starts_with_case = matches!(items[0].elements.first(), Some(Element::Case(_))); + if style == ListStyle::AfterKeyword + && count == 1 + && !force + && items[0].leading_comments.is_empty() + && !starts_with_case + { + // a lone item stays against its keyword, however it's written: moving it to the next line + // only when it's too long would leave whatever breaks within it deciding the line it starts + // on, which the next time it's formatted would decide differently + let mut result = PrintItems::new(); + result.push_space(); + result.extend(gen_list_item(&items[0], context)); + return result; + } + let force = force + || items.iter().enumerate().any(|(i, item)| { + (i + 1 < count && item.has_trailing_line_comment()) + || !item.leading_comments.is_empty() + || !item.after_comments.is_empty() + }) + || (style == ListStyle::InBrackets && items.last().is_some_and(|item| item.has_trailing_line_comment())); + let indent_width = context.config.indent_width; + // a string spanning lines doesn't break up the list it's in + let has_multi_line_text_item = items.iter().any(|item| is_multi_line_text_item(item.elements)); + // nor do brackets that are the only item, which stay against what's before them (ex. `FROM (`) + let is_lone_group = count == 1 && items[0].leading_comments.is_empty() && lone_group(items[0].elements).is_some(); + gen_nested_separated_values( + context, + |context| { + let mut line = 0; + let mut values = Vec::with_capacity(count); + for (i, item) in items.iter().enumerate() { + if i > 0 { + line += if item.blank_line_before { 2 } else { 1 }; + } + let is_known_multi_line = item.is_known_multi_line(i + 1 == count, style); + let item_items = gen_list_item(item, context); + values.push(GeneratedValue { + // a lone item in brackets breaks within its brackets before the list breaks before it + items: if is_lone_group { + item_items + } else { + ir_helpers::new_line_group(item_items) + }, + lines_span: Some(LinesSpan { + start_line: line, + end_line: line, + }), + allow_inline_multi_line: is_lone_group || is_multi_line_text_item(item.elements), + allow_inline_single_line: has_multi_line_text_item, + is_known_multi_line, + }); + } + values + }, + GenSeparatedValuesOptions { + prefer_hanging: false, + force_use_new_lines: force, + allow_blank_lines: style == ListStyle::InBrackets, + indent_width, + single_line_options: match style { + ListStyle::AfterKeyword => SingleLineOptions::separated_line_starting_with_space(), + ListStyle::InBrackets => SingleLineOptions::separated_same_line(Signal::SpaceOrNewLine.into()), + }, + multi_line_options: match style { + ListStyle::AfterKeyword => MultiLineOptions::new_line_start(), + ListStyle::InBrackets => MultiLineOptions::surround_newlines_indented(), + }, + force_possible_newline_at_start: false, + }, + ) + .items +} + +fn has_template_around_commas(elements: &[Element]) -> bool { + elements.iter().enumerate().any(|(i, element)| { + element.is_kind(TokenKind::TemplateLine) + || (element + .as_token() + .is_some_and(|t| t.kind == TokenKind::Template && t.text.starts_with("{%")) + && (elements.get(i + 1).is_some_and(|e| e.is_kind(TokenKind::Comma)) + || i.checked_sub(1).is_some_and(|i| elements[i].is_kind(TokenKind::Comma)))) + }) +} + +fn gen_list_item(item: &ListItem, context: &mut Context) -> PrintItems { + let mut items = PrintItems::new(); + items.extend(gen_leading_comments( + &item.leading_comments, + item.elements.first().map(|e| e.first_token()), + )); + items.extend(gen_fill(item.elements, context)); + if item.comma.is_some() { + items.push_str(","); + } + for comment in &item.trailing_comments { + items.extend(gen_trailing_comment(comment)); + } + for comment in &item.after_comments { + items.push_signal(Signal::NewLine); + items.extend(gen_comment(comment)); + } + items +} + +// ---- conditions ---- + +struct ConditionPart<'e, 'a> { + leading_comments: Vec>, + /// The `AND` or `OR` before the condition, or the `ON` of a join. + operator: Option>, + elements: &'e [Element<'a>], +} + +impl ConditionPart<'_, '_> { + /// Whether the condition is written over several lines however the conditions are written. A + /// line comment at the end of the last condition doesn't count, since the line ends there anyway. + fn is_known_multi_line(&self, is_last: bool) -> bool { + let elements = if is_last { + without_trailing_comments(self.elements) + } else { + self.elements + }; + !self.leading_comments.is_empty() || is_known_multi_line(elements) + } +} + +/// Splits conditions at the `AND` and `OR` between them, which doesn't include the `AND` of a +/// `BETWEEN`. +fn split_conditions<'e, 'a>(elements: &'e [Element<'a>]) -> Vec> { + let mut parts = Vec::new(); + let mut start = 0; + let mut operator = None; + let mut in_between = false; + for (i, element) in elements.iter().enumerate() { + let Some(token) = element.as_token().filter(|t| t.kind == TokenKind::Word) else { + continue; + }; + if token.is_word("BETWEEN") { + in_between = true; + } else if token.is_word("AND") && in_between { + in_between = false; + } else if (token.is_word("AND") || token.is_word("OR")) && i > start { + parts.push((operator, &elements[start..i])); + operator = Some(token); + start = i + 1; + } + } + parts.push((operator, &elements[start..])); + + let mut result: Vec = Vec::with_capacity(parts.len()); + let mut carried = Vec::new(); + let count = parts.len(); + for (index, (operator, part)) in parts.into_iter().enumerate() { + let mut part = part; + let leading_comments = std::mem::take(&mut carried); + if index + 1 < count { + // comments on lines of their own before an `AND` introduce the condition after it + let split = own_line_comments_start(part); + carried = part[split..].iter().filter_map(|e| e.as_token()).collect(); + part = &part[..split]; + } + result.push(ConditionPart { + leading_comments, + operator, + elements: part, + }); + } + result +} + +/// Conditions after a keyword, of which the first stays on the keyword's line and the rest are +/// indented on lines of their own when they don't fit (ex. `WHERE a = 1` then ` AND b = 2`). +fn gen_conditions_hanging(elements: &[Element], context: &mut Context) -> PrintItems { + let parts = split_conditions(elements); + gen_condition_parts( + None, + &parts, + MultiLineOptions::same_line_start_hanging_indent(), + false, + context, + ) +} + +fn gen_condition_parts( + first: Option, + parts: &[ConditionPart], + multi_line_options: MultiLineOptions, + force: bool, + context: &mut Context, +) -> PrintItems { + if first.is_none() && parts.len() == 1 && parts[0].leading_comments.is_empty() { + return gen_condition_part(&parts[0], context); + } + let force = force || parts.iter().skip(1).any(|part| !part.leading_comments.is_empty()); + let indent_width = context.config.indent_width; + gen_nested_separated_values( + context, + |context| { + let mut values = Vec::with_capacity(parts.len() + 1); + if let Some(first) = first { + values.push(GeneratedValue::from_items(first)); + } + for (i, part) in parts.iter().enumerate() { + values.push(GeneratedValue { + items: ir_helpers::new_line_group(gen_condition_part(part, context)), + lines_span: None, + allow_inline_multi_line: false, + allow_inline_single_line: false, + is_known_multi_line: part.is_known_multi_line(i + 1 == parts.len()), + }); + } + values + }, + GenSeparatedValuesOptions { + prefer_hanging: false, + force_use_new_lines: force, + allow_blank_lines: false, + indent_width, + single_line_options: SingleLineOptions::separated_same_line(Signal::SpaceOrNewLine.into()), + multi_line_options, + force_possible_newline_at_start: false, + }, + ) + .items +} + +fn gen_condition_part(part: &ConditionPart, context: &mut Context) -> PrintItems { + let mut items = PrintItems::new(); + let first_token = part.operator.or_else(|| part.elements.first().map(|e| e.first_token())); + items.extend(gen_leading_comments(&part.leading_comments, first_token)); + if let Some(operator) = part.operator { + items.extend(gen_keyword(operator, context)); + items.extend(space_or_comment_spacing_opt(part.elements.first())); + } + items.extend(gen_fill(part.elements, context)); + items +} + +// ---- from and joins ---- + +fn gen_from(body: &[Element], force_list: bool, context: &mut Context) -> PrintItems { + let mut join_starts = Vec::new(); + let mut i = 0; + while i < body.len() { + if let Some(len) = join_keyword_len(body, i) { + join_starts.push((i, len)); + i += len; + } else { + i += 1; + } + } + let refs_end = join_starts.first().map(|(start, _)| *start).unwrap_or(body.len()); + let mut refs = &body[..refs_end]; + let mut pending = Vec::new(); + if !join_starts.is_empty() { + let split = own_line_comments_start(refs); + pending = refs[split..].iter().filter_map(|e| e.as_token()).collect(); + refs = &refs[..split]; + } + + let mut items = gen_list(refs, ListStyle::AfterKeyword, force_list, context); + for (index, (start, len)) in join_starts.iter().enumerate() { + let end = join_starts + .get(index + 1) + .map(|(start, _)| *start) + .unwrap_or(body.len()); + let mut rest = &body[start + len..end]; + let leading_comments = std::mem::take(&mut pending); + if index + 1 < join_starts.len() { + let split = own_line_comments_start(rest); + pending = rest[split..].iter().filter_map(|e| e.as_token()).collect(); + rest = &rest[..split]; + } + let mut join = PrintItems::new(); + join.push_signal(Signal::NewLine); + join.extend(gen_leading_comments( + &leading_comments, + Some(body[*start].first_token()), + )); + join.extend(gen_join(&body[*start..start + len], rest, context)); + if context.config.joins_as_top_level { + items.extend(join); + } else { + items.extend(ir_helpers::with_indent(join)); + } + } + items +} + +/// How many words starting at `i` make up the keyword of a join (ex. `LEFT OUTER JOIN`). +fn join_keyword_len(elements: &[Element], i: usize) -> Option { + if prev_significant(elements, i).is_some_and(|e| e.is_kind(TokenKind::Dot)) { + return None; + } + let word = |offset: usize| word_at(elements, i + offset); + let first = word(0)?; + if first.is_word("STRAIGHT_JOIN") { + return Some(1); + } + if (first.is_word("CROSS") || first.is_word("OUTER")) && word(1).is_some_and(|t| t.is_word("APPLY")) { + return Some(2); + } + if first.is_word("LATERAL") && word(1).is_some_and(|t| t.is_word("VIEW")) { + return Some(if word(2).is_some_and(|t| t.is_word("OUTER")) { + 3 + } else { + 2 + }); + } + let mut len = 0; + for optional in [ + &["NATURAL", "ASOF", "GLOBAL"][..], + &["INNER", "CROSS", "LEFT", "RIGHT", "FULL", "POSITIONAL"], + &["OUTER"], + &["SEMI", "ANTI"], + &["HASH", "MERGE", "LOOP", "REMOTE"], + ] { + if word(len).is_some_and(|t| optional.iter().any(|w| t.is_word(w))) { + len += 1; + } + } + word(len).is_some_and(|t| t.is_word("JOIN")).then_some(len + 1) +} + +fn gen_join(keyword: &[Element], rest: &[Element], context: &mut Context) -> PrintItems { + let mut items = gen_fill_ex(keyword, context, true); + context.table_name_start = rest.iter().find(|e| !e.is_comment()).map(|e| e.first_token().start); + if rest.is_empty() { + return items; + } + items.extend(space_or_comment_spacing(&rest[0])); + match rest.iter().position(|e| e.is_word("ON")) { + Some(on_index) if on_index > 0 => { + let table = gen_fill(&rest[..on_index], context); + let mut parts = split_conditions(&rest[on_index + 1..]); + parts[0].operator = rest[on_index].as_token(); + items.extend(gen_condition_parts( + Some(table), + &parts, + MultiLineOptions::same_line_start_hanging_indent(), + false, + context, + )); + } + _ => items.extend(gen_fill(rest, context)), + } + items +} + +// ---- with ---- + +/// The common table expressions after a `WITH`, each starting a line of its own after the first. +fn gen_with(body: &[Element], context: &mut Context) -> PrintItems { + let mut items = PrintItems::new(); + for (i, item) in split_list(body).iter().enumerate() { + if i > 0 { + items.push_signal(Signal::NewLine); + } + items.extend(gen_list_item(item, context)); + } + items +} + +// ---- merge ---- + +fn gen_merge_when(body: &[Element], context: &mut Context) -> PrintItems { + let Some(then_index) = body.iter().position(|e| e.is_word("THEN")) else { + return gen_fill(body, context); + }; + let mut items = gen_fill(&body[..then_index], context); + items.extend(space_if_not_start_of_line()); + items.extend(gen_fill_ex(&body[then_index..then_index + 1], context, true)); + let action = &body[then_index + 1..]; + if action.is_empty() { + return items; + } + let mut action_items = PrintItems::new(); + action_items.push_signal(Signal::NewLine); + if action.first().is_some_and(|e| e.is_word("UPDATE")) && action.get(1).is_some_and(|e| e.is_word("SET")) { + action_items.extend(gen_fill_ex(&action[..2], context, true)); + action_items.extend(gen_list(&action[2..], ListStyle::AfterKeyword, false, context)); + } else { + action_items.extend(gen_fill(action, context)); + } + items.extend(ir_helpers::with_indent(action_items)); + items +} + +// ---- groups ---- + +fn gen_group(group: &Group, context: &mut Context) -> PrintItems { + let mut items = ir_helpers::gen_from_string(group.open.text); + let elements = &group.elements; + if elements.iter().all(|e| e.is_comment()) { + if !elements.is_empty() { + let mut comments = PrintItems::new(); + for (i, element) in elements.iter().enumerate() { + let token = element.as_token().unwrap(); + if i == 0 && token.newlines_before == 0 { + comments.extend(gen_trailing_comment(token)); + } else { + comments.push_signal(Signal::NewLine); + comments.extend(gen_comment(token)); + } + } + items.extend(ir_helpers::with_indent(comments)); + items.push_signal(Signal::NewLine); + } + items.extend(ir_helpers::gen_from_string(group.close.text)); + return items; + } + + let force = group_forces_multi_line(group); + if is_query_start(elements) || (group.is_paren() && detect_mode(elements) == Mode::Window) { + let clauses = split_clauses(elements); + let force = force || clauses.iter().any(|clause| clause.is_known_multi_line()); + items.extend(gen_clauses_fit(&clauses, force, ClausesLayout::InBrackets, context)); + } else if !elements.iter().any(|e| e.is_kind(TokenKind::Comma)) && has_condition_operator(elements) { + let parts = split_conditions(elements); + let force = force || parts.iter().any(|part| part.is_known_multi_line(false)); + items.extend(gen_condition_parts( + None, + &parts, + MultiLineOptions::surround_newlines_indented(), + force, + context, + )); + } else { + items.extend(gen_list(elements, ListStyle::InBrackets, force, context)); + } + items.extend(ir_helpers::gen_from_string(group.close.text)); + items +} + +/// Whether the brackets are written over several lines however wide the line is: when the author +/// started their contents on a new line, or when something within them can't be written on one. +/// Brackets that are the only thing within them stay against them however they're written, so +/// they don't count. +fn group_forces_multi_line(group: &Group) -> bool { + let starts_new_line = group + .elements + .first() + .is_some_and(|e| e.first_token().newlines_before > 0); + starts_new_line + || match lone_group(&group.elements) { + Some(_) => group.elements.iter().any(|e| e.is_line_comment()), + None => is_known_multi_line(&group.elements), + } +} + +/// The brackets that are all there is to the elements apart from an alias after them (ex. +/// `(SELECT ..) AS t`), which are written against whatever is before them even when they're +/// written over several lines. +fn lone_group<'e, 'a>(elements: &'e [Element<'a>]) -> Option<&'e Group<'a>> { + let mut significant = elements.iter().filter(|e| !e.is_comment()); + let Some(Element::Group(group)) = significant.next() else { + return None; + }; + let rest: Vec<&Element> = significant.collect(); + let is_name = |e: &Element| { + e.is_kind(TokenKind::QuotedIdent) + || (e.is_kind(TokenKind::Word) + && !e + .as_token() + .is_some_and(|t| keywords::contains(keywords::RESERVED, t.text))) + }; + let is_alias = match rest.as_slice() { + [] => true, + [name] => is_name(name), + [as_keyword, name] => as_keyword.is_word("AS") && is_name(name), + _ => false, + }; + is_alias.then_some(group) +} + +fn has_condition_operator(elements: &[Element]) -> bool { + let mut in_between = false; + for element in elements { + if element.is_word("BETWEEN") { + in_between = true; + } else if element.is_word("AND") && in_between { + in_between = false; + } else if element.is_word("AND") || element.is_word("OR") { + return true; + } + } + false +} + +// ---- case ---- + +fn gen_case(case: &Case, context: &mut Context) -> PrintItems { + let mut items = gen_keyword(case.case_keyword, context); + if !case.operand.is_empty() { + items.push_space(); + items.extend(gen_fill(&case.operand, context)); + } + let force = case_forces_multi_line(case); + let indent_width = context.config.indent_width; + let branch_count = case.branches.len() + usize::from(case.else_branch.is_some()); + items.extend( + gen_nested_separated_values( + context, + |context| { + let mut values = Vec::with_capacity(branch_count); + for branch in &case.branches { + let mut items = PrintItems::new(); + items.extend(gen_leading_comments(&branch.leading_comments, Some(branch.when))); + items.extend(gen_keyword(branch.when, context)); + items.extend(space_or_comment_spacing_opt(branch.condition.first())); + items.extend(gen_fill(&branch.condition, context)); + items.extend(space_if_not_start_of_line()); + items.extend(gen_keyword(branch.then, context)); + items.extend(space_or_comment_spacing_opt(branch.result.first())); + items.extend(gen_fill(&branch.result, context)); + values.push(GeneratedValue::from_items(ir_helpers::new_line_group(items))); + } + if let Some(else_branch) = &case.else_branch { + let mut items = PrintItems::new(); + items.extend(gen_leading_comments( + &else_branch.leading_comments, + Some(else_branch.keyword), + )); + items.extend(gen_keyword(else_branch.keyword, context)); + items.extend(space_or_comment_spacing_opt(else_branch.result.first())); + items.extend(gen_fill(&else_branch.result, context)); + values.push(GeneratedValue::from_items(ir_helpers::new_line_group(items))); + } + if !case.end_comments.is_empty() + && let Some(last) = values.last_mut() + { + for comment in &case.end_comments { + last.items.push_signal(Signal::NewLine); + last.items.extend(gen_comment(comment)); + } + } + values + }, + GenSeparatedValuesOptions { + prefer_hanging: false, + force_use_new_lines: force, + allow_blank_lines: false, + indent_width, + single_line_options: SingleLineOptions::surrounded_line(), + multi_line_options: MultiLineOptions::surround_newlines_indented(), + force_possible_newline_at_start: false, + }, + ) + .items, + ); + items.extend(gen_keyword(case.end, context)); + items +} + +/// Whether a `CASE` is written over several lines however wide the line is: when the author +/// started its first `WHEN` on a new line, or when something within it can't be written on one. +fn case_forces_multi_line(case: &Case) -> bool { + let first_branch_token = case + .branches + .first() + .map(|b| b.leading_comments.first().copied().unwrap_or(b.when)); + first_branch_token.is_some_and(|t| t.newlines_before > 0) + || !case.end_comments.is_empty() + || is_known_multi_line(&case.operand) + || case + .branches + .iter() + .any(|b| !b.leading_comments.is_empty() || is_known_multi_line(&b.condition) || is_known_multi_line(&b.result)) + || case + .else_branch + .as_ref() + .is_some_and(|e| !e.leading_comments.is_empty() || is_known_multi_line(&e.result)) +} + +// ---- elements ---- + +fn gen_fill(elements: &[Element], context: &mut Context) -> PrintItems { + gen_fill_ex(elements, context, false) +} + +/// Writes the elements one after another as they flow, with only comments breaking the line. +/// `all_keywords` says that every word is a keyword (ex. the words of a clause's keyword). +fn gen_fill_ex(elements: &[Element], context: &mut Context, all_keywords: bool) -> PrintItems { + let mut items = PrintItems::new(); + let angle_types = angle_bracket_types(elements); + for (i, element) in elements.iter().enumerate() { + if i > 0 { + let previous = &elements[i - 1]; + if angle_types + .iter() + .any(|range| range.contains(&(i - 1)) && range.contains(&i)) + { + // the parameters of a type in angle brackets keep their spacing + if element.first_token().space_before { + items.push_space(); + } + } else if element.is_comment() { + let token = element.as_token().unwrap(); + if token.newlines_before > 0 { + items.push_signal(Signal::NewLine); + } else if !previous.is_line_comment() { + items.extend(space_if_not_start_of_line()); + } + } else if previous.is_line_comment() { + // the comment already ended the line + } else if previous.is_comment() { + let first = element.first_token(); + if first.newlines_before > 0 { + items.push_signal(Signal::NewLine); + } else if first.space_before { + items.push_space(); + } + } else if previous.last_token().kind == TokenKind::String + && element.first_token().kind == TokenKind::String + && element.first_token().newlines_before > 0 + { + // PostgreSQL only joins strings that are separated by a line break + items.push_signal(Signal::NewLine); + } else if needs_space(elements, i) || would_join(previous.last_token(), element.first_token(), context) { + items.push_space(); + } + } + items.extend(gen_element(elements, i, context, all_keywords)); + } + items +} + +fn gen_element(elements: &[Element], i: usize, context: &mut Context, all_keywords: bool) -> PrintItems { + match &elements[i] { + Element::Token(token) => { + let is_keyword = token.kind == TokenKind::Word + && (all_keywords || is_keyword_at(elements, i)) + && !(context.table_name_start == Some(token.start) && is_likely_table_name(token)); + gen_token(token, is_keyword, context) + } + Element::Group(group) => gen_group(group, context), + Element::Case(case) => gen_case(case, context), + element => gen_procedural(element, context), + } +} + +/// Whether the word at `i` is a keyword: one that's always reserved, or one that's a keyword +/// after the word before it (ex. the `KEY` of `PRIMARY KEY`). A word that's part of a qualified +/// name (ex. `t.order`) is a name. +fn is_keyword_at(elements: &[Element], i: usize) -> bool { + const CONTEXTUAL: &[(&str, &[&str])] = &[ + ("ACTION", &["NO"]), + ("FIRST", &["NULLS", "FETCH"]), + ("KEY", &["PRIMARY", "FOREIGN", "UNIQUE", "DUPLICATE"]), + ("LAST", &["NULLS"]), + ("MATCHED", &["NOT"]), + ("MODE", &["SHARE"]), + ("NEXT", &["FETCH"]), + ("NOTHING", &["DO"]), + ("ONLY", &["ROWS", "ROW"]), + ("PRECISION", &["DOUBLE"]), + ("ROW", &["CURRENT", "EACH"]), + ("SOURCE", &["BY"]), + ("TARGET", &["BY"]), + ("TIES", &["WITH"]), + ("VARYING", &["CHARACTER", "CHAR"]), + ("ZONE", &["TIME"]), + ]; + let Some(token) = elements[i].as_token() else { + return false; + }; + let previous = prev_significant(elements, i); + if previous.is_some_and(|e| e.is_kind(TokenKind::Dot)) + || elements.get(i + 1).is_some_and(|e| e.is_kind(TokenKind::Dot)) + { + return false; + } + if keywords::contains(keywords::RESERVED, token.text) { + return !(previous.and_then(|e| e.as_token()).is_some_and(names_table) && is_likely_table_name(token)); + } + // words that are keywords before the word after them + let next = elements.get(i + 1).and_then(|e| e.as_token()); + let next_is = |words: &[&str]| next.is_some_and(|next| words.iter().any(|w| next.is_word(w))); + if (token.is_word("NO") && next_is(&["ACTION"])) + || ((token.is_word("ROW") || token.is_word("ROWS")) + && next_is(&["ONLY", "BETWEEN", "UNBOUNDED", "PRECEDING", "FOLLOWING", "CURRENT"])) + { + return true; + } + let Some(previous) = previous.and_then(|e| e.as_token()) else { + return false; + }; + if (token.is_word("ROW") || token.is_word("ROWS")) && previous.kind == TokenKind::Number { + return true; + } + if previous.is_operator("::") { + return keywords::contains(keywords::TYPES, token.text); + } + CONTEXTUAL + .iter() + .any(|(word, after)| token.is_word(word) && after.iter().any(|a| previous.is_word(a))) +} + +fn gen_token(token: &Token, is_keyword: bool, context: &mut Context) -> PrintItems { + match token.kind { + TokenKind::Word => gen_word(token, is_keyword, context), + TokenKind::LineComment | TokenKind::BlockComment | TokenKind::TemplateLine => gen_comment(token), + TokenKind::DollarString if context.code_bodies.contains(&token.start) => gen_code_body(token, context), + _ if token.is_multi_line() => gen_raw_text(token.text), + _ => ir_helpers::gen_from_string(token.text), + } +} + +/// Whether a table's name follows `token` (ex. `FROM`). +fn names_table(token: &Token) -> bool { + ["FROM", "JOIN", "INTO", "UPDATE", "TABLE"] + .iter() + .any(|w| token.is_word(w)) +} + +/// Whether `token` is a reserved word that's also a likely name of a table, which keeps its case +/// where a table is named (ex. `FROM view`), since that may matter (ex. in MySQL on Linux). +fn is_likely_table_name(token: &Token) -> bool { + [ + "DATABASE", + "FUNCTION", + "HANDLER", + "INDEX", + "SCHEMA", + "TRANSACTION", + "TRIGGER", + "VIEW", + ] + .iter() + .any(|w| token.is_word(w)) +} + +/// How deeply values separated onto lines when they don't fit on one may nest before they're +/// always put on lines of their own, since the printer's work to fit them grows exponentially +/// with their depth. +const MAX_FITTED_DEPTH: u32 = 12; + +fn gen_nested_separated_values( + context: &mut Context, + gen_values: impl FnOnce(&mut Context) -> Vec, + mut opts: GenSeparatedValuesOptions, +) -> GenSeparatedValuesResult { + context.separated_values_depth += 1; + opts.force_use_new_lines |= context.separated_values_depth > MAX_FITTED_DEPTH; + let result = ir_helpers::gen_separated_values(|_| gen_values(context), opts); + context.separated_values_depth -= 1; + result +} + +/// Text written exactly as it is, including the line breaks within it. The first line is +/// indented like any other, but the lines after it aren't, since the text is a token whose +/// contents can't change (ex. a string spanning lines). +fn gen_raw_text(text: &str) -> PrintItems { + let mut items = PrintItems::new(); + items.push_force_current_line_indentation(); + items.extend(ir_helpers::gen_from_raw_string(text)); + items +} + +fn gen_word(token: &Token, is_keyword: bool, context: &Context) -> PrintItems { + let mut items = PrintItems::new(); + match (is_keyword, context.config.uppercase) { + (true, UppercaseKind::Upper) => items.push_string(token.text.to_ascii_uppercase()), + (true, UppercaseKind::Lower) => items.push_string(token.text.to_ascii_lowercase()), + _ => items.push_str(token.text), + } + items +} + +fn gen_keyword(token: &Token, context: &Context) -> PrintItems { + gen_word(token, token.kind == TokenKind::Word, context) +} + +fn gen_keywords(tokens: &[Tok], context: &Context) -> PrintItems { + let mut items = PrintItems::new(); + for (i, token) in tokens.iter().enumerate() { + if i > 0 { + items.push_space(); + } + items.extend(gen_keyword(token, context)); + } + items +} + +/// The code in a dollar-quoted string, such as the body of a PL/pgSQL function, which is +/// formatted as SQL when it can be. The code starts on the line after the opening quote, without +/// any indentation, since it's still the contents of a string. +fn gen_code_body(token: &Token, context: &mut Context) -> PrintItems { + let text = token.text; + let tag_len = text[1..].find('$').map(|i| i + 2).unwrap_or(text.len()); + if text.len() < tag_len * 2 { + return gen_raw_text(text); + } + let tag = &text[..tag_len]; + let inner = &text[tag_len..text.len() - tag_len]; + if inner.trim().is_empty() { + return gen_raw_text(text); + } + let Some(formatted) = crate::format_text::format_embedded(inner, context.config) else { + return gen_raw_text(text); + }; + let formatted = formatted.trim_end_matches('\n'); + let result = if !inner.contains('\n') && !formatted.contains('\n') { + format!("{tag} {formatted} {tag}") + } else { + format!("{tag}\n{formatted}\n{tag}") + }; + gen_raw_text(&result) +} + +/// Whether a space goes between the element at `i` and the one before it, neither of which is a +/// comment. +/// Whether two tokens that are apart in the text would read as something else written against each +/// other (ex. `@ a` as the variable `@a`, or `{ {` as the start of a template). +fn would_join(previous: &Token, next: &Token, context: &Context) -> bool { + if !next.space_before && next.newlines_before == 0 { + return false; + } + let text = format!("{}{}", previous.text, next.text); + match crate::lexer::lex(&text, context.config.dialect) { + Ok(tokens) => !(tokens.len() == 2 && tokens[0].text == previous.text && tokens[1].text == next.text), + Err(_) => true, + } +} + +fn needs_space(elements: &[Element], i: usize) -> bool { + let previous = &elements[i - 1]; + let next = &elements[i]; + let next_first = next.first_token(); + + if next.is_kind(TokenKind::Comma) || next.is_kind(TokenKind::Semicolon) { + return false; + } + if previous.is_kind(TokenKind::Comma) || previous.is_kind(TokenKind::Semicolon) { + return true; + } + if previous.is_kind(TokenKind::Dot) || next.is_kind(TokenKind::Dot) { + // keep apart a dot and a number that would otherwise read as one number (ex. `\.` then `2`) + let separated = next_first.space_before || next_first.newlines_before > 0; + return separated + && (previous.is_kind(TokenKind::Number) || next_first.text.starts_with(|c: char| c.is_ascii_digit())); + } + if let Some(operator) = next.as_token().filter(|t| t.kind == TokenKind::Operator) { + return match operator.text { + "::" => false, + ":" | ".." => operator.space_before, + // an attribute like `%TYPE` or `%NOTFOUND` in PL/SQL and PL/pgSQL, which a `%` written + // against what's on both sides of it might be + "%" if is_attribute_operator(elements, i) => false, + _ => true, + }; + } + if let Some(operator) = previous.as_token().filter(|t| t.kind == TokenKind::Operator) { + return match operator.text { + "::" => false, + "%" if is_attribute_operator(elements, i - 1) => false, + ":" | ".." => next_first.space_before, + // a `*` where a unary operator would be is a wildcard (ex. `SELECT *`) + "*" => true, + _ => !(is_unary_operator(operator) && is_unary_position(elements, i - 1)), + }; + } + if let Element::Group(group) = next { + return match group.open.kind { + TokenKind::OpenParen => match previous { + Element::Token(token) if token.kind == TokenKind::Word => { + keywords::contains(keywords::SPACE_BEFORE_PAREN, token.text) || group.open.space_before + } + Element::Case(_) => true, + _ => group.open.space_before, + }, + _ => group.open.space_before, + }; + } + if previous.is_kind(TokenKind::Other) || next.is_kind(TokenKind::Other) { + return next_first.space_before; + } + if matches!(previous, Element::Group(_)) { + return !next.is_kind(TokenKind::Template) || next_first.space_before; + } + // two operands or words, which stay together when the source has them together (ex. MySQL's + // `_utf8'text'`) + next_first.space_before +} + +/// The ranges of the elements that are a type's parameters in angle brackets, from the `<` to the +/// `>` (ex. BigQuery's `ARRAY` or `STRUCT`), whose `<` and `>` aren't +/// comparisons. +fn angle_bracket_types(elements: &[Element]) -> Vec> { + let mut ranges = Vec::new(); + let mut i = 1; + while i < elements.len() { + let opens = elements[i] + .as_token() + .is_some_and(|t| t.is_operator("<") && !t.space_before) + && elements[i - 1].as_token().is_some_and(|t| { + ["ARRAY", "STRUCT", "MAP", "RANGE", "TABLE"] + .iter() + .any(|w| t.is_word(w)) + }); + if !opens { + i += 1; + continue; + } + let mut depth = 0i32; + let mut end = None; + for (j, element) in elements.iter().enumerate().skip(i) { + if let Some(token) = element.as_token().filter(|t| t.kind == TokenKind::Operator) { + depth += token.text.matches('<').count() as i32 - token.text.matches('>').count() as i32; + if depth <= 0 { + end = Some(j); + break; + } + } + } + match end { + Some(end) => { + // along with what's against them on either side + ranges.push(i - 1..=(end + 1).min(elements.len() - 1)); + i = end + 1; + } + None => i += 1, + } + } + ranges +} + +/// Whether the `%` at `i` is written against what's on both sides of it, as the `%` of an +/// attribute (ex. `employees.name%TYPE`) is. +fn is_attribute_operator(elements: &[Element], i: usize) -> bool { + let operator = elements[i].first_token(); + !operator.space_before + && elements + .get(i + 1) + .is_some_and(|next| next.is_kind(TokenKind::Word) && !next.first_token().space_before) +} + +fn is_unary_operator(token: &Token) -> bool { + matches!(token.text, "-" | "+" | "~" | "!" | "@") +} + +/// Whether an operator at `i` is in the position of a unary one: at the start, or after another +/// operator, a comma or a keyword that doesn't stand for a value. +fn is_unary_position(elements: &[Element], i: usize) -> bool { + match prev_significant(elements, i) { + None => true, + Some(Element::Token(token)) => match token.kind { + TokenKind::Operator | TokenKind::Comma | TokenKind::Semicolon => true, + TokenKind::Word => { + keywords::contains(keywords::RESERVED, token.text) && !keywords::contains(keywords::VALUE_KEYWORDS, token.text) + } + _ => false, + }, + Some(_) => false, + } +} + +// ---- comments ---- + +fn gen_comment(token: &Token) -> PrintItems { + match token.kind { + TokenKind::LineComment | TokenKind::TemplateLine => { + let text = token.text.trim_end_matches([' ', '\t']); + // a template tag spanning lines (ex. `{% if a` then `and b %}`) is written as it is + let mut items = if token.is_multi_line() { + gen_raw_text(text) + } else { + ir_helpers::gen_from_string(text) + }; + items.push_signal(Signal::ExpectNewLine); + items + } + _ if token.is_multi_line() => gen_raw_text(token.text), + _ => ir_helpers::gen_from_string(token.text), + } +} + +/// Comments that start lines of their own before something, which keep whatever was on the line +/// after them on that line, and otherwise end it. `next` is the first token of what they're +/// before. +fn gen_leading_comments(comments: &[Tok], next: Option) -> PrintItems { + let mut items = PrintItems::new(); + for (i, comment) in comments.iter().enumerate() { + items.extend(gen_comment(comment)); + let following = comments.get(i + 1).copied().or(next); + if !comment.is_line_comment() && following.is_some_and(|t| t.newlines_before == 0) { + items.push_space(); + } else { + items.push_signal(Signal::NewLine); + } + } + items +} + +fn gen_trailing_comment(token: &Token) -> PrintItems { + let mut items = space_if_not_start_of_line(); + items.extend(gen_comment(token)); + items +} + +fn space_if_not_start_of_line() -> PrintItems { + if_false( + "spaceIfNotStartOfLine", + condition_resolvers::is_start_of_line(), + " ".into(), + ) + .into() +} + +/// The space after a keyword and before the element that follows it, or a line break when that +/// element is a comment on a line of its own. +fn space_or_comment_spacing(next: &Element) -> PrintItems { + match next.as_token() { + Some(token) if token.is_comment() && token.newlines_before > 0 => Signal::NewLine.into(), + _ => space_if_not_start_of_line(), + } +} + +fn space_or_comment_spacing_opt(next: Option<&Element>) -> PrintItems { + next.map(space_or_comment_spacing).unwrap_or_default() +} + +// ---- helpers ---- + +fn word_at<'a>(elements: &[Element<'a>], i: usize) -> Option> { + elements.get(i)?.as_token().filter(|t| t.kind == TokenKind::Word) +} + +fn word_or_string_at(elements: &[Element], i: usize) -> bool { + elements + .get(i) + .is_some_and(|e| e.is_kind(TokenKind::Word) || e.is_kind(TokenKind::String) || e.is_kind(TokenKind::QuotedIdent)) +} + +/// The element before `i` that isn't a comment. +fn prev_significant<'e, 'a>(elements: &'e [Element<'a>], i: usize) -> Option<&'e Element<'a>> { + elements[..i].iter().rev().find(|e| !e.is_comment()) +} + +/// Where the comments at the end of the elements that are on lines of their own start, which is +/// the end of the elements when there aren't any. A comment on the line of what's before it ends +/// that line rather than introducing what's next, so it isn't one of them. +fn own_line_comments_start(elements: &[Element]) -> usize { + let mut split = elements.len(); + while split > 0 && elements[split - 1].is_comment() { + split -= 1; + } + while split < elements.len() && elements[split].first_token().newlines_before == 0 { + split += 1; + } + split +} + +/// Whether the elements start a query (ex. a subquery's `SELECT`). +fn is_query_start(elements: &[Element]) -> bool { + let Some((index, first)) = elements.iter().enumerate().find(|(_, e)| !e.is_comment()) else { + return false; + }; + match first { + Element::Group(group) => is_query_start(&group.elements), + Element::Token(token) => { + token.is_word("SELECT") + || token.is_word("VALUES") + || token.is_word("TABLE") + || (token.is_word("WITH") && is_cte_start(elements, index)) + } + _ => false, + } +} + +/// Whether the `WITH` at `i` begins common table expressions rather than a table hint or an +/// option (ex. `WITH (NOLOCK)` or `WITH TIES`). +fn is_cte_start(elements: &[Element], i: usize) -> bool { + let mut rest = elements[i + 1..].iter().filter(|e| !e.is_comment()); + match rest.next() { + Some(e) if e.is_word("RECURSIVE") => true, + Some(e) if e.is_kind(TokenKind::Word) || e.is_kind(TokenKind::QuotedIdent) => match rest.next() { + Some(e) if e.is_word("AS") => true, + Some(Element::Group(_)) => rest.next().is_some_and(|e| e.is_word("AS")), + _ => false, + }, + _ => false, + } +} + +/// Whether the elements are written over several lines however wide the line is, because of a +/// comment that ends a line or brackets that are kept on several lines. +fn is_known_multi_line(elements: &[Element]) -> bool { + elements.iter().enumerate().any(|(i, element)| match element { + Element::Token(token) => token.is_line_comment() || (token.is_comment() && i > 0 && token.newlines_before > 0), + Element::Group(group) => group_forces_multi_line(group), + Element::Case(case) => case_forces_multi_line(case), + Element::Label(_) => false, + _ => true, + }) +} + +/// The elements without the comments at their end that are on the line of what's before them. +fn without_trailing_comments<'e, 'a>(elements: &'e [Element<'a>]) -> &'e [Element<'a>] { + let mut end = elements.len(); + while end > 0 + && elements[end - 1] + .as_token() + .is_some_and(|t| t.is_comment() && t.newlines_before == 0) + { + end -= 1; + } + &elements[..end] +} + +/// Whether a list item is written over several lines only because of a string with a line break +/// in it (ex. a string spanning lines with an alias after it), which then doesn't break up the +/// list it's in. +/// +/// Brackets rule that out, since whether they're broken depends on where the list puts them, and +/// a list that stays on its line because of brackets broken the last time it was formatted would +/// be written differently each time. +fn is_multi_line_text_item(elements: &[Element]) -> bool { + elements + .iter() + .any(|element| element.as_token().is_some_and(|token| token.is_multi_line())) + && elements.iter().all(|element| matches!(element, Element::Token(_))) +} diff --git a/src/generation/mod.rs b/src/generation/mod.rs new file mode 100644 index 0000000..9dc6a41 --- /dev/null +++ b/src/generation/mod.rs @@ -0,0 +1,5 @@ +mod context; +mod generate; + +pub use context::*; +pub use generate::*; diff --git a/src/keywords.rs b/src/keywords.rs new file mode 100644 index 0000000..577a56e --- /dev/null +++ b/src/keywords.rs @@ -0,0 +1,461 @@ +// Tables of words that mean something to the formatter. +// +// Each table is sorted and uppercase, which `contains` relies on to look a word up without +// allocating. The tests at the bottom check that they stay that way. + +use std::cmp::Ordering; + +/// For a word naming a kind of object that a `CREATE` or `ALTER` may be of, whether it's a routine +/// (a procedure, function, trigger, package or event), whose body is code. `None` when it names no +/// kind, or one that may come before the kind (ex. the `TYPE` of `CREATE TYPE BODY`). +pub fn object_kind_is_routine(word: &str) -> Option { + if !contains(OBJECT_KINDS, word) + || ["AGGREGATE", "CONSTRAINT", "TYPE"] + .iter() + .any(|w| word.eq_ignore_ascii_case(w)) + { + return None; + } + Some( + ["PROCEDURE", "PROC", "FUNCTION", "TRIGGER", "PACKAGE", "BODY", "EVENT"] + .iter() + .any(|kind| word.eq_ignore_ascii_case(kind)), + ) +} + +/// Whether `word` is in `sorted_words`, ignoring case. +pub fn contains(sorted_words: &[&str], word: &str) -> bool { + sorted_words + .binary_search_by(|candidate| cmp_ignore_ascii_case(candidate, word)) + .is_ok() +} + +fn cmp_ignore_ascii_case(a: &str, b: &str) -> Ordering { + let a = a.bytes().map(|b| b.to_ascii_uppercase()); + let b = b.bytes().map(|b| b.to_ascii_uppercase()); + a.cmp(b) +} + +/// Words that are keywords wherever they appear, since they're reserved in the dialects that use +/// them (or never make sense as a name). These are the ones the `casing` option changes, and the +/// ones after which a `-` is a sign rather than a minus. +/// +/// Words that are as often names as keywords (ex. `name`, `key`, `first`, `language`) are left +/// out. Where they're used as keywords is worked out from what's around them instead. +pub const RESERVED: &[&str] = &[ + "ADD", + "ALL", + "ALTER", + "AND", + "ANY", + "ARRAY", + "AS", + "ASC", + "AUTHORIZATION", + "AUTOINCREMENT", + "AUTO_INCREMENT", + "BEGIN", + "BETWEEN", + "BIGINT", + "BOOLEAN", + "BOTH", + "BY", + "CALL", + "CASCADE", + "CASE", + "CAST", + "CHECK", + "CLOSE", + "CLUSTERED", + "COLLATE", + "COLUMN", + "COMMIT", + "CONSTRAINT", + "CONTINUE", + "CREATE", + "CROSS", + "CUBE", + "CURRENT", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "CURSOR", + "DATABASE", + "DEALLOCATE", + "DECIMAL", + "DECLARE", + "DEFAULT", + "DEFERRABLE", + "DELETE", + "DENY", + "DESC", + "DESCRIBE", + "DETERMINISTIC", + "DISTINCT", + "DO", + "DROP", + "EACH", + "ELSE", + "ELSEIF", + "ELSIF", + "END", + "ESCAPE", + "EXCEPT", + "EXCEPTION", + "EXEC", + "EXECUTE", + "EXISTS", + "EXIT", + "EXPLAIN", + "FALSE", + "FETCH", + "FILTER", + "FOLLOWING", + "FOR", + "FOREACH", + "FOREIGN", + "FROM", + "FULL", + "FUNCTION", + "GOTO", + "GRANT", + "GROUP", + "HANDLER", + "HAVING", + "IDENTITY", + "IF", + "ILIKE", + "IN", + "INDEX", + "INNER", + "INOUT", + "INSERT", + "INT", + "INTEGER", + "INTERSECT", + "INTERVAL", + "INTO", + "IS", + "ISNULL", + "JOIN", + "LATERAL", + "LEADING", + "LEAVE", + "LEFT", + "LIKE", + "LIMIT", + "LOCALTIME", + "LOCALTIMESTAMP", + "LOOP", + "MATCHED", + "MERGE", + "MINUS", + "NATURAL", + "NCHAR", + "NONCLUSTERED", + "NOT", + "NOTNULL", + "NULL", + "NULLS", + "NUMERIC", + "NVARCHAR", + "OF", + "OFFSET", + "ON", + "OPEN", + "OR", + "ORDER", + "OUTER", + "OVER", + "PARTITION", + "PRECEDING", + "PRIMARY", + "PRINT", + "PROC", + "PROCEDURE", + "QUALIFY", + "RAISE", + "RAISERROR", + "RECURSIVE", + "REFERENCES", + "REGEXP", + "REPEAT", + "REPLACE", + "RESTRICT", + "RETURN", + "RETURNING", + "RETURNS", + "REVOKE", + "RIGHT", + "RLIKE", + "ROLLBACK", + "ROLLUP", + "SAVEPOINT", + "SCHEMA", + "SELECT", + "SET", + "SIMILAR", + "SMALLINT", + "SOME", + "TABLE", + "TEMPORARY", + "THEN", + "THROW", + "TINYINT", + "TO", + "TOP", + "TRAILING", + "TRAN", + "TRANSACTION", + "TRIGGER", + "TRUE", + "TRUNCATE", + "UNBOUNDED", + "UNION", + "UNIQUE", + "UNKNOWN", + "UNTIL", + "UPDATE", + "USING", + "VALUES", + "VARCHAR", + "VARCHAR2", + "VIEW", + "WHEN", + "WHERE", + "WHILE", + "WINDOW", + "WITH", + "WITHIN", + "XOR", +]; + +/// Keywords that stand for a value, so an operator after one is a binary operator. +pub const VALUE_KEYWORDS: &[&str] = &[ + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "END", + "FALSE", + "LOCALTIME", + "LOCALTIMESTAMP", + "NULL", + "TRUE", + "UNKNOWN", +]; + +/// Keywords that are followed by a space before an opening parenthesis, as opposed to ones that +/// are called like functions or name a type (ex. `CAST(`, `VARCHAR(`, `ANY(`), where the source's +/// spacing is kept. +pub const SPACE_BEFORE_PAREN: &[&str] = &[ + "AND", + "AS", + "BETWEEN", + "BY", + "CASE", + "CONFLICT", + "DO", + "ELSE", + "EXCEPT", + "EXISTS", + "FILTER", + "FROM", + "GROUP", + "HAVING", + "IN", + "INTERSECT", + "IS", + "JOIN", + "LATERAL", + "LIKE", + "MINUS", + "NOT", + "ON", + "OR", + "OVER", + "RETURN", + "SELECT", + "SET", + "THEN", + "UNION", + "USING", + "WHEN", + "WHERE", + "WHILE", + "WITH", +]; + +/// Words that start a T-SQL statement, which is where the condition of an `IF` or `WHILE` ends. +pub const TSQL_STATEMENT_KEYWORDS: &[&str] = &[ + "ALTER", + "BACKUP", + "BEGIN", + "BREAK", + "BULK", + "CHECKPOINT", + "CLOSE", + "COMMIT", + "CONTINUE", + "CREATE", + "DBCC", + "DEALLOCATE", + "DECLARE", + "DELETE", + "DENY", + "DISABLE", + "DROP", + "ENABLE", + "EXEC", + "EXECUTE", + "FETCH", + "GOTO", + "GRANT", + "IF", + "INSERT", + "KILL", + "MERGE", + "OPEN", + "PRINT", + "RAISERROR", + "RECONFIGURE", + "RESTORE", + "RETURN", + "REVOKE", + "ROLLBACK", + "SAVE", + "SELECT", + "SET", + "SETUSER", + "SHUTDOWN", + "THROW", + "TRUNCATE", + "UPDATE", + "USE", + "WAITFOR", + "WHILE", + "WITH", +]; + +/// The kinds of objects that are created and dropped, which an `IF [NOT] EXISTS` may follow. +pub const OBJECT_KINDS: &[&str] = &[ + "AGGREGATE", + "ASSEMBLY", + "BODY", + "COLUMN", + "CONSTRAINT", + "DATABASE", + "DOMAIN", + "EVENT", + "EXTENSION", + "FUNCTION", + "INDEX", + "KEY", + "LOGIN", + "PACKAGE", + "PARTITION", + "POLICY", + "PROC", + "PROCEDURE", + "ROLE", + "RULE", + "SCHEMA", + "SEQUENCE", + "SERVER", + "STATISTICS", + "SYNONYM", + "TABLE", + "TABLESPACE", + "TRIGGER", + "TYPE", + "USER", + "VIEW", +]; + +/// Names of types, which are keywords where a type is expected (ex. after `::`). +pub const TYPES: &[&str] = &[ + "BIGINT", + "BIGSERIAL", + "BINARY", + "BIT", + "BLOB", + "BOOL", + "BOOLEAN", + "BYTEA", + "CHAR", + "CHARACTER", + "CIDR", + "CLOB", + "DATE", + "DATETIME", + "DATETIME2", + "DECIMAL", + "DOUBLE", + "FLOAT", + "INET", + "INT", + "INT2", + "INT4", + "INT8", + "INTEGER", + "INTERVAL", + "JSON", + "JSONB", + "MONEY", + "NUMBER", + "NUMERIC", + "NVARCHAR", + "REAL", + "REGCLASS", + "SERIAL", + "SMALLINT", + "TEXT", + "TIME", + "TIMESTAMP", + "TIMESTAMPTZ", + "TIMETZ", + "TINYINT", + "UUID", + "VARBINARY", + "VARCHAR", + "XML", +]; + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn tables_are_sorted_and_uppercase() { + for table in [ + RESERVED, + VALUE_KEYWORDS, + SPACE_BEFORE_PAREN, + TSQL_STATEMENT_KEYWORDS, + OBJECT_KINDS, + TYPES, + ] { + for pair in table.windows(2) { + assert_eq!( + cmp_ignore_ascii_case(pair[0], pair[1]), + Ordering::Less, + "{} should come before {}", + pair[0], + pair[1] + ); + } + for word in table { + assert_eq!(word.to_ascii_uppercase(), *word); + } + } + } + + #[test] + fn looks_up_ignoring_case() { + assert!(contains(RESERVED, "select")); + assert!(contains(RESERVED, "SeLeCt")); + assert!(!contains(RESERVED, "name")); + assert!(contains(RESERVED, "auto_increment")); + } +} diff --git a/src/lexer.rs b/src/lexer.rs new file mode 100644 index 0000000..6de94ce --- /dev/null +++ b/src/lexer.rs @@ -0,0 +1,1833 @@ +// Splits SQL text into tokens. +// +// The lexer is lossless: every character of the input belongs to a token or to the whitespace +// between two of them, so the formatter only ever decides what whitespace goes between tokens and +// never what the tokens are. Where the dialects disagree on what some text is (ex. `#` starts a +// comment in MySQL, a temporary table's name in SQL Server and is an operator in PostgreSQL), the +// configured dialect decides, and the generic one picks whatever is least likely to be wrong. + +use std::ops::Range; + +use crate::configuration::Dialect; +use crate::keywords; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenKind { + /// An unquoted identifier or keyword, including a T-SQL temporary table's name (`#temp`). + Word, + /// A quoted identifier (`"name"`, `` `name` `` or `[name]`). + QuotedIdent, + /// A string literal, including any prefix (`N'..'`, `E'..'`, `X'..'`, `q'[..]'`). + String, + /// A dollar-quoted string (`$$..$$` or `$tag$..$tag$`). + DollarString, + Number, + /// A variable or parameter (`@name`, `@@name`, `:name`, `$1`, `?`, `${name}`). + Variable, + Operator, + Comma, + Semicolon, + Dot, + OpenParen, + CloseParen, + OpenBracket, + CloseBracket, + OpenBrace, + CloseBrace, + /// A comment that runs to the end of the line (`-- ..`, `# ..` or `// ..`). + LineComment, + /// A `/* .. */` comment. + BlockComment, + /// A template expression or tag (`{{ .. }}`, `{% .. %}` or `{# .. #}`). + Template, + /// A template tag or comment on a line of its own (ex. `{% if x %}`), which is treated like a + /// line comment: it keeps its line, and whatever comes after it starts the next one. + TemplateLine, + /// A statement terminator other than `;` set by a MySQL `DELIMITER` command. + Delimiter, + /// Separates batches of statements: `GO` in SQL Server and `/` in Oracle, on a line of its own. + BatchSeparator, + /// A command to a client rather than SQL, which takes up its whole line (ex. `DELIMITER //`, a + /// psql `\connect` or an `sqlcmd` `:setvar`). + Directive, + /// A character that is none of the above. + Other, +} + +#[derive(Debug, Clone)] +pub struct Token<'a> { + pub kind: TokenKind, + pub text: &'a str, + /// Where the token starts in the text. + pub start: usize, + /// How many line breaks are in the whitespace before the token. + pub newlines_before: u32, + /// Whether any whitespace comes before the token. + pub space_before: bool, +} + +impl Token<'_> { + pub fn end(&self) -> usize { + self.start + self.text.len() + } + + /// Whether this is the word `word`, ignoring case. `word` is expected in uppercase. + pub fn is_word(&self, word: &str) -> bool { + self.kind == TokenKind::Word && self.text.eq_ignore_ascii_case(word) + } + + pub fn is_comment(&self) -> bool { + matches!( + self.kind, + TokenKind::LineComment | TokenKind::BlockComment | TokenKind::TemplateLine + ) + } + + /// Whether this token ends its line however the text after it is written. + pub fn is_line_comment(&self) -> bool { + matches!(self.kind, TokenKind::LineComment | TokenKind::TemplateLine) + } + + pub fn is_operator(&self, op: &str) -> bool { + self.kind == TokenKind::Operator && self.text == op + } + + /// Whether the token is the first thing on its line. + pub fn starts_line(&self) -> bool { + self.newlines_before > 0 + } + + /// Whether the token is written over more than one line. + pub fn is_multi_line(&self) -> bool { + self.text.contains('\n') + } +} + +#[derive(Debug)] +pub struct LexError { + pub message: String, + pub span: Range, +} + +pub fn lex(text: &str, dialect: Dialect) -> Result>, LexError> { + let mut lexer = Lexer { + text, + pos: 0, + dialect, + delimiter: None, + tokens: Vec::new(), + newlines: 0, + space: false, + line_start: true, + open_brackets: 0, + last_significant: None, + }; + lexer.run()?; + Ok(lexer.tokens) +} + +struct Lexer<'a> { + text: &'a str, + pos: usize, + dialect: Dialect, + /// The statement terminator set by a MySQL `DELIMITER` command, when it isn't `;`. + delimiter: Option<&'a str>, + tokens: Vec>, + newlines: u32, + space: bool, + /// Whether only whitespace precedes the current position on its line. + line_start: bool, + /// How many `[` that aren't quoted identifiers are open. + open_brackets: i32, + /// The kind of the last token that isn't a comment. + last_significant: Option, +} + +impl<'a> Lexer<'a> { + fn run(&mut self) -> Result<(), LexError> { + loop { + self.skip_whitespace(); + if self.pos >= self.text.len() { + return Ok(()); + } + let start = self.pos; + let kind = self.next_kind()?; + self.push(kind, start); + if kind == TokenKind::Semicolon { + for _ in 0..self.copy_from_stdin_count() { + if !self.copy_data() { + break; + } + } + } + } + } + + /// How many of the statements just ended are psql's `COPY .. FROM STDIN`, each of which the + /// data to copy follows on the lines after it, up to a line holding only `\.`. psql sends the + /// statements separated by `\;` together, so there may be more than one. + fn copy_from_stdin_count(&self) -> usize { + if !matches!(self.dialect, Dialect::PostgreSql | Dialect::Generic) { + return 0; + } + let statement_start = self.tokens[..self.tokens.len() - 1] + .iter() + .rposition(|token| { + matches!( + token.kind, + TokenKind::Semicolon | TokenKind::Delimiter | TokenKind::BatchSeparator | TokenKind::Directive + ) + }) + .map(|index| index + 1) + .unwrap_or(0); + let statement = &self.tokens[statement_start..self.tokens.len() - 1]; + statement + .split(|token| token.kind == TokenKind::Other && token.text == "\\;") + .filter(|part| is_copy_from_stdin(part)) + .count() + } + + /// The data after a `COPY .. FROM STDIN`, which is kept as it is. Returns whether there was any. + fn copy_data(&mut self) -> bool { + let Some(line_end) = self.rest().find('\n') else { + return false; + }; + // anything after the `;` on its line is still SQL, so the data starts on the next line, but a + // comment there can't be data + let rest_of_line = self.rest()[..line_end].trim_matches(is_space); + if !rest_of_line.is_empty() && !rest_of_line.starts_with("--") { + return false; + } + if !rest_of_line.is_empty() { + self.skip_whitespace(); + let start = self.pos; + let kind = self.line_comment(); + self.push(kind, start); + return self.copy_data(); + } + let data_start = self.pos + line_end + 1; + let mut end = self.text.len(); + let mut line_start = data_start; + while line_start < self.text.len() { + let line_end = self.text[line_start..] + .find('\n') + .map(|i| line_start + i) + .unwrap_or(self.text.len()); + if self.text[line_start..line_end].trim_end_matches(is_space) == "\\." { + end = line_start + self.text[line_start..line_end].trim_end_matches(is_space).len(); + break; + } + line_start = line_end + 1; + } + if end <= data_start { + return false; + } + self.pos = end; + self.newlines = 1; + self.push(TokenKind::Directive, data_start); + true + } + + fn push(&mut self, kind: TokenKind, start: usize) { + match kind { + TokenKind::OpenBracket => self.open_brackets += 1, + TokenKind::CloseBracket => self.open_brackets -= 1, + _ => {} + } + if !matches!( + kind, + TokenKind::LineComment | TokenKind::BlockComment | TokenKind::TemplateLine + ) { + self.last_significant = Some(kind); + } + self.tokens.push(Token { + kind, + text: &self.text[start..self.pos], + start, + newlines_before: self.newlines, + space_before: self.space || self.newlines > 0, + }); + self.newlines = 0; + self.space = false; + self.line_start = false; + } + + fn skip_whitespace(&mut self) { + while let Some(c) = self.peek() { + match c { + '\n' => { + self.newlines += 1; + self.line_start = true; + } + ' ' | '\t' | '\r' | '\x0b' | '\x0c' => self.space = true, + _ => return, + } + self.pos += c.len_utf8(); + } + } + + fn next_kind(&mut self) -> Result { + if self.line_start + && let Some(kind) = self.line_command() + { + return Ok(kind); + } + if self.is_go_after_semicolon() { + self.pos += 2; + return Ok(TokenKind::BatchSeparator); + } + if let Some(delimiter) = self.delimiter + && self.rest().starts_with(delimiter) + { + self.pos += delimiter.len(); + return Ok(TokenKind::Delimiter); + } + + let c = self.peek().unwrap(); + let next = self.peek_at(1); + match c { + '-' if next == Some('-') && self.is_double_dash_comment() => Ok(self.line_comment()), + '/' if next == Some('*') => self.block_comment(), + '/' if next == Some('/') && self.dialect == Dialect::Snowflake => Ok(self.line_comment()), + '#' => Ok(self.hash()), + '\'' => self.string(1, self.backslash_escapes()), + '"' => self.double_quoted(), + '`' => self.quoted('`', TokenKind::QuotedIdent, self.dialect == Dialect::BigQuery), + '[' => self.open_bracket(), + ']' => Ok(self.single(TokenKind::CloseBracket)), + '{' => self.open_brace(), + '}' => Ok(self.single(TokenKind::CloseBrace)), + '(' => Ok(self.single(TokenKind::OpenParen)), + ')' => Ok(self.single(TokenKind::CloseParen)), + ',' => Ok(self.single(TokenKind::Comma)), + ';' => Ok(self.single(TokenKind::Semicolon)), + '.' => Ok(self.dot()), + '@' => Ok(self.at()), + ':' => Ok(self.colon()), + '?' => Ok(self.question_mark()), + '$' => self.dollar(), + '%' if self.is_python_placeholder() => Ok(TokenKind::Variable), + '&' if self.dialect == Dialect::Oracle && next.is_some_and(|c| c == '&' || is_ident_start(c)) => { + // SQL*Plus substitution variable + self.pos += if next == Some('&') { 2 } else { 1 }; + self.eat_ident_chars(); + Ok(TokenKind::Variable) + } + // psql's `\;`, which separates statements sent to the server together rather than ending one + '\\' if next == Some(';') && matches!(self.dialect, Dialect::PostgreSql | Dialect::Generic) => { + self.pos += 2; + Ok(TokenKind::Other) + } + // a psql meta-command, which runs to the end of its line wherever it starts, and ends the + // query before it (ex. `SELECT 1 \gset`) + '\\' + if next.is_some_and(|c| c.is_ascii_alphabetic()) + && matches!(self.dialect, Dialect::PostgreSql | Dialect::Generic) => + { + self.pos += self.rest_of_line().trim_end_matches(is_space).len(); + Ok(TokenKind::Directive) + } + '\\' => Ok(self.single(TokenKind::Other)), + c if c.is_ascii_digit() => Ok(self.number()), + c if is_ident_start(c) => self.word(), + c if is_operator_char(c) => Ok(self.operator()), + _ => Ok(self.single(TokenKind::Other)), + } + } + + // ---- line commands ---- + + /// A command to the client, which takes up a line of its own. + fn line_command(&mut self) -> Option { + let line = self.rest_of_line(); + let trimmed = line.trim_end_matches(is_space); + let first_word_len = trimmed.find(is_space).unwrap_or(trimmed.len()); + let first_word = &trimmed[..first_word_len]; + let after_first = trimmed[first_word_len..].trim_start_matches(is_space); + + if first_word.eq_ignore_ascii_case("delimiter") + && matches!(self.dialect, Dialect::MySql | Dialect::Generic) + && self.is_between_statements() + && let Some(delimiter) = self.delimiter_command_value(after_first) + { + self.pos += trimmed.len(); + self.delimiter = if delimiter == ";" { None } else { Some(delimiter) }; + return Some(TokenKind::Directive); + } + + if matches!(self.dialect, Dialect::SqlServer | Dialect::Generic) && first_word.eq_ignore_ascii_case("go") { + // `GO` or `GO 5`, optionally followed by a comment + let rest = after_first; + let count_len = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); + let after_count = rest[count_len..].trim_start_matches(is_space); + if after_count.is_empty() || after_count.starts_with("--") { + let len = trimmed.len() - after_count.len(); + self.pos += line[..len].trim_end_matches(is_space).len(); + return Some(TokenKind::BatchSeparator); + } + } + + if matches!(self.dialect, Dialect::Oracle | Dialect::Generic) && trimmed == "/" { + self.pos += 1; + return Some(TokenKind::BatchSeparator); + } + + // a client's commands are only between statements, since a line of a statement may start the + // same way (ex. a T-SQL parameter `@name` or an Oracle `SET` clause) + let is_directive = self.is_between_statements() + && match self.dialect { + Dialect::SqlServer => is_sqlcmd_command(trimmed), + Dialect::Oracle => is_sql_plus_command(first_word, after_first), + Dialect::Generic => is_sqlcmd_command(trimmed), + _ => false, + }; + if is_directive { + self.pos += trimmed.len(); + // a SQL*Plus command continues onto the next line after a ` -` (ex. `EXEC p( -`), though + // a remark or a prompt is often a line of dashes + let continues = self.dialect == Dialect::Oracle + && !["rem", "remark", "pro", "prompt"] + .iter() + .any(|c| c.eq_ignore_ascii_case(first_word)); + while continues + && (self.text[..self.pos].ends_with(" -") || self.text[..self.pos].ends_with("\t-")) + && self.rest().trim_start_matches([' ', '\t']).starts_with(['\n', '\r']) + { + let next_line = self.rest().trim_start_matches([' ', '\t']); + let next_line = next_line.strip_prefix('\r').unwrap_or(next_line); + let next_line = next_line.strip_prefix('\n').unwrap_or(next_line); + let line_end = next_line.find('\n').unwrap_or(next_line.len()); + let line_len = next_line[..line_end].trim_end_matches(is_space).len(); + if line_len == 0 { + break; + } + self.pos = self.text.len() - next_line.len() + line_len; + } + return Some(TokenKind::Directive); + } + None + } + + /// Whether this is a `GO` at the end of a line after a statement's `;` (ex. `SELECT 1; GO`), + /// which is a batch separator like one on a line of its own, since a statement can't continue + /// after the `;`. + fn is_go_after_semicolon(&self) -> bool { + matches!(self.dialect, Dialect::SqlServer | Dialect::Generic) + && self.newlines == 0 + && self + .tokens + .last() + .is_some_and(|token| token.kind == TokenKind::Semicolon) + && self.rest().get(..2).is_some_and(|word| word.eq_ignore_ascii_case("go")) + && { + let after = self.rest_of_line()[2..].trim_matches(is_space); + after.is_empty() || after.starts_with("--") + } + } + + /// Whether the previous token ends a statement, so that what's next can't be part of one. + fn is_between_statements(&self) -> bool { + self.last_significant.is_none_or(|kind| { + matches!( + kind, + TokenKind::Semicolon | TokenKind::Delimiter | TokenKind::Directive | TokenKind::BatchSeparator + ) + }) + } + + /// The delimiter a `DELIMITER` command sets, from the text that follows the word. + fn delimiter_command_value(&self, value: &'a str) -> Option<&'a str> { + if value.is_empty() || value.contains(is_space) || value.contains(['(', ')']) || value.ends_with(',') { + return None; + } + // a column named `delimiter` is far more likely in the generic dialect than a delimiter made + // of letters + if self.dialect == Dialect::Generic && value.chars().any(|c| c.is_alphanumeric()) { + return None; + } + Some(value) + } + + // ---- comments ---- + + fn is_double_dash_comment(&self) -> bool { + // MySQL only reads `--` as a comment when whitespace follows it + self.dialect != Dialect::MySql || self.peek_at(2).is_none_or(|c| is_space(c) || c.is_control()) + } + + fn line_comment(&mut self) -> TokenKind { + let line = self.rest_of_line(); + self.pos += line.strip_suffix('\r').unwrap_or(line).len(); + TokenKind::LineComment + } + + fn block_comment(&mut self) -> Result { + let start = self.pos; + let nested = matches!( + self.dialect, + Dialect::PostgreSql | Dialect::SqlServer | Dialect::Generic + ); + if nested && let Some(end) = find_nested_comment_end(&self.text[start..]) { + self.pos = start + end; + return Ok(TokenKind::BlockComment); + } + // a comment that only ends when nesting is ignored is read that way, since the generic dialect + // can't know which the author meant + if (!nested || self.dialect == Dialect::Generic) + && let Some(end) = self.text[start + 2..].find("*/") + { + self.pos = start + 2 + end + 2; + return Ok(TokenKind::BlockComment); + } + // SQLite lets a comment run to the end of the input + if self.dialect == Dialect::Sqlite { + self.pos = start + self.text[start..].trim_end_matches(is_space).len(); + return Ok(TokenKind::BlockComment); + } + Err(self.error(start, "unterminated block comment")) + } + + fn hash(&mut self) -> TokenKind { + let next = self.peek_at(1); + let starts_name = next.is_some_and(|c| c == '#' || is_ident_start(c)); + match self.dialect { + Dialect::MySql | Dialect::BigQuery => self.line_comment(), + Dialect::SqlServer if starts_name => self.temp_table_name(), + Dialect::PostgreSql => self.operator(), + Dialect::Generic => { + if next.is_none_or(is_space) || next == Some('!') { + self.line_comment() + } else if next == Some('@') && self.peek_at(2).is_some_and(is_ident_start) && !self.line_start { + // a T-SQL temporary table whose name starts with `@` (ex. `#@temp`), as in SQL Server + self.single(TokenKind::Other) + } else if next.is_some_and(|c| is_operator_char(c) && c != '#') && !self.line_start { + // a PostgreSQL operator (ex. `#>` or `#%#`), though at the start of a line it's far more + // likely a MySQL comment (ex. `#=== section ===`) + self.operator() + } else if starts_name + && !self.is_between_statements() + && (!self.line_start || self.previous_continues_onto_next_line()) + { + self.temp_table_name() + } else { + self.line_comment() + } + } + _ => self.single(TokenKind::Other), + } + } + + fn temp_table_name(&mut self) -> TokenKind { + self.pos += if self.peek_at(1) == Some('#') { 2 } else { 1 }; + self.eat_ident_chars(); + TokenKind::Word + } + + /// Whether the previous token leaves its statement wanting more, such that a `#name` on the + /// next line is more likely a temporary table's name than a comment. + fn previous_continues_onto_next_line(&self) -> bool { + self.last_significant.is_some_and(|kind| { + matches!( + kind, + TokenKind::Word | TokenKind::Comma | TokenKind::OpenParen | TokenKind::Dot + ) + }) + } + + // ---- strings and quoted identifiers ---- + + fn backslash_escapes(&self) -> bool { + matches!(self.dialect, Dialect::MySql | Dialect::BigQuery | Dialect::Snowflake) + } + + /// A string starting `prefix_len` characters before the opening quote at `self.pos + prefix_len - 1`. + fn string(&mut self, prefix_len: usize, backslash_escapes: bool) -> Result { + let start = self.pos; + let quote_pos = start + prefix_len - 1; + if self.dialect == Dialect::BigQuery && self.text[quote_pos..].starts_with("'''") { + return self.triple_quoted(start, quote_pos, "'''"); + } + self.pos = quote_pos; + self.quoted_from(start, '\'', TokenKind::String, backslash_escapes) + } + + fn double_quoted(&mut self) -> Result { + match self.dialect { + Dialect::BigQuery if self.rest().starts_with("\"\"\"") => self.triple_quoted(self.pos, self.pos, "\"\"\""), + Dialect::BigQuery => self.quoted('"', TokenKind::String, true), + Dialect::MySql => self.quoted('"', TokenKind::String, true), + _ => self.quoted('"', TokenKind::QuotedIdent, false), + } + } + + fn quoted(&mut self, quote: char, kind: TokenKind, backslash_escapes: bool) -> Result { + self.quoted_from(self.pos, quote, kind, backslash_escapes) + } + + /// Reads from the opening quote at `self.pos` to the closing one. A doubled quote is an escaped + /// quote rather than the end, as is one after a backslash when `backslash_escapes` is set. + fn quoted_from( + &mut self, + start: usize, + quote: char, + kind: TokenKind, + backslash_escapes: bool, + ) -> Result { + let mut chars = self.text[self.pos + 1..].char_indices(); + while let Some((i, c)) = chars.next() { + if backslash_escapes && c == '\\' { + chars.next(); + } else if c == quote { + if self.text[self.pos + 1 + i + 1..].starts_with(quote) { + chars.next(); + } else { + self.pos += 1 + i + 1; + return Ok(kind); + } + } + } + Err(self.error( + start, + match kind { + TokenKind::QuotedIdent => "unterminated quoted identifier", + _ => "unterminated string", + }, + )) + } + + fn triple_quoted(&mut self, start: usize, quote_pos: usize, quotes: &str) -> Result { + let content_start = quote_pos + 3; + let mut i = content_start; + let bytes = self.text.as_bytes(); + while i < self.text.len() { + if bytes[i] == b'\\' { + i += 2; + } else if bytes[i..].starts_with(quotes.as_bytes()) { + self.pos = i + 3; + return Ok(TokenKind::String); + } else { + i += 1; + } + } + Err(self.error(start, "unterminated string")) + } + + /// An Oracle quoted string (`q'[text]'`), whose delimiter is the character after the quote. + fn oracle_quoted_string(&mut self, prefix_len: usize) -> Result { + let start = self.pos; + let delimiter_pos = start + prefix_len + 1; + let Some(open) = self.text[delimiter_pos..].chars().next() else { + return Err(self.error(start, "unterminated string")); + }; + let close = match open { + '[' => ']', + '{' => '}', + '(' => ')', + '<' => '>', + c => c, + }; + let content_start = delimiter_pos + open.len_utf8(); + let mut search = content_start; + while let Some(index) = self.text[search..].find(close) { + let close_pos = search + index; + if self.text[close_pos + close.len_utf8()..].starts_with('\'') { + self.pos = close_pos + close.len_utf8() + 1; + return Ok(TokenKind::String); + } + search = close_pos + close.len_utf8(); + } + Err(self.error(start, "unterminated string")) + } + + fn open_bracket(&mut self) -> Result { + let is_identifier = match self.dialect { + Dialect::SqlServer | Dialect::Sqlite => true, + Dialect::Generic => self.generic_bracket_is_identifier(), + _ => false, + }; + if is_identifier { + let start = self.pos; + // a `]` is escaped by doubling it, which the generic dialect doesn't allow for, since a + // doubled `]` there is more likely the end of nested arrays (ex. `ARRAY[[1, 2]]`) + let mut i = start + 1; + while let Some(index) = self.text[i..].find(']') { + let close = i + index; + if self.dialect != Dialect::Generic && self.text[close + 1..].starts_with(']') { + i = close + 2; + } else { + self.pos = close + 1; + return Ok(TokenKind::QuotedIdent); + } + } + if self.dialect == Dialect::Generic { + return Ok(self.single(TokenKind::OpenBracket)); + } + return Err(self.error(start, "unterminated quoted identifier")); + } + Ok(self.single(TokenKind::OpenBracket)) + } + + /// Whether a `[` is a T-SQL quoted identifier rather than the start of an array or a subscript. + /// + /// A subscript follows what it indexes without a space (`arr[1]`, `ARRAY[1, 2]`), and the + /// contents of a quoted identifier stay on one line without nesting brackets. Getting this wrong + /// is harmless: a quoted identifier is written as it is, so all that's lost is formatting + /// within the brackets. + fn generic_bracket_is_identifier(&self) -> bool { + let follows_value = !self.space + && self.newlines == 0 + && self.tokens.last().is_some_and(|token| { + matches!( + token.kind, + TokenKind::Word + | TokenKind::QuotedIdent + | TokenKind::CloseParen + | TokenKind::CloseBracket + | TokenKind::Variable + ) + }); + // brackets within an array's brackets are another array (ex. `ARRAY[[1, 2], [3, 4]]`) + if follows_value || self.open_brackets > 0 { + return false; + } + let line = self.rest_of_line(); + match line[1..].find(']') { + Some(index) => { + let content = &line[1..1 + index]; + !content.is_empty() && !content.contains('[') && !content.trim_matches(is_space).is_empty() + } + None => false, + } + } + + fn open_brace(&mut self) -> Result { + let close = match self.peek_at(1) { + Some('{') => "}}", + Some('%') => "%}", + Some('#') => "#}", + _ => return Ok(self.single(TokenKind::OpenBrace)), + }; + let start = self.pos; + match self.text[start + 2..].find(close) { + Some(index) => { + self.pos = start + 2 + index + 2; + let ends_line = self.rest_of_line().trim_matches(is_space).is_empty(); + let is_tag = close != "}}"; + Ok(if is_tag && self.line_start && ends_line { + TokenKind::TemplateLine + } else { + TokenKind::Template + }) + } + None => Err(self.error(start, "unterminated template tag")), + } + } + + // ---- variables and parameters ---- + + fn at(&mut self) -> TokenKind { + if self.dialect == Dialect::PostgreSql { + return self.operator(); + } + // a Snowflake stage and the path in it (ex. `@my_stage/path/file.csv`, `@~` or `@%table`) + if self.dialect == Dialect::Snowflake + && self + .peek_at(1) + .is_some_and(|c| c == '~' || c == '%' || is_ident_start(c)) + { + self.pos += 1; + self.pos += self + .rest() + .find(|c: char| c.is_ascii_whitespace() || matches!(c, ',' | ';' | '(' | ')' | '\'' | '"')) + .unwrap_or(self.rest().len()); + return TokenKind::Variable; + } + let start = self.pos; + let mut i = start + 1; + if self.text[i..].starts_with('@') { + i += 1; + } + match self.text[i..].chars().next() { + Some(c) if is_ident_start(c) => { + self.pos = i; + self.eat_variable_chars(); + TokenKind::Variable + } + // a quoted MySQL user variable (`@'name'`) + Some(quote @ ('\'' | '"' | '`')) if i == start + 1 && self.dialect != Dialect::PostgreSql => { + self.pos = i; + match self.quoted_from(start, quote, TokenKind::Variable, false) { + Ok(kind) => kind, + Err(_) => { + self.pos = start; + self.operator() + } + } + } + _ => self.operator(), + } + } + + fn colon(&mut self) -> TokenKind { + match self.peek_at(1) { + Some(':') | Some('=') => { + self.pos += 2; + TokenKind::Operator + } + Some(c) if is_ident_start(c) || c.is_ascii_digit() => { + self.pos += 1; + self.eat_variable_chars(); + TokenKind::Variable + } + // a psql variable interpolated as a literal or an identifier (`:'name'` or `:"name"`) + Some(quote @ ('\'' | '"')) if matches!(self.dialect, Dialect::PostgreSql | Dialect::Generic) => { + let start = self.pos; + self.pos += 1; + match self.quoted_from(start, quote, TokenKind::Variable, false) { + Ok(kind) => kind, + Err(_) => { + self.pos = start + 1; + TokenKind::Operator + } + } + } + _ => self.single(TokenKind::Operator), + } + } + + fn question_mark(&mut self) -> TokenKind { + // a parameter can't follow a value, so a `?` that does is PostgreSQL's operator (ex. `a?'key'`) + let follows_value = self.tokens.last().is_some_and(|token| { + matches!( + token.kind, + TokenKind::Word + | TokenKind::QuotedIdent + | TokenKind::String + | TokenKind::Number + | TokenKind::CloseParen + | TokenKind::CloseBracket + ) + }) && !self + .tokens + .last() + .is_some_and(|token| keywords::contains(keywords::RESERVED, token.text) && token.kind == TokenKind::Word); + if self.dialect == Dialect::PostgreSql && (follows_value || matches!(self.peek_at(1), Some('|' | '&' | '-' | '#'))) + { + return self.operator(); + } + self.pos += 1; + while self.peek().is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1; + } + TokenKind::Variable + } + + fn dollar(&mut self) -> Result { + let start = self.pos; + let next = self.peek_at(1); + if next.is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1; + while self.peek().is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1; + } + return Ok(TokenKind::Variable); + } + if next == Some('{') { + return match self.text[start..].find('}') { + Some(index) => { + self.pos = start + index + 1; + Ok(TokenKind::Variable) + } + None => Ok(self.single(TokenKind::Other)), + }; + } + if let Some(tag_len) = self.dollar_quote_tag_len() { + let tag = &self.text[start..start + tag_len]; + return match self.text[start + tag_len..].find(tag) { + Some(index) => { + self.pos = start + tag_len + index + tag_len; + Ok(TokenKind::DollarString) + } + None => Err(self.error(start, "unterminated dollar-quoted string")), + }; + } + if next.is_some_and(is_ident_start) { + self.pos += 1; + self.eat_ident_chars(); + return Ok(TokenKind::Variable); + } + Ok(self.single(TokenKind::Other)) + } + + /// The length of the dollar quote's tag (`$tag$` or `$$`) starting at the current position. + fn dollar_quote_tag_len(&self) -> Option { + if !matches!( + self.dialect, + Dialect::PostgreSql | Dialect::Snowflake | Dialect::Generic + ) { + return None; + } + let rest = &self.text[self.pos + 1..]; + for (i, c) in rest.char_indices() { + if c == '$' { + return Some(i + 2); + } + // `$` is an identifier character, so the tag ends at the first one rather than going on + let is_valid = if i == 0 { + is_ident_start(c) + } else { + is_ident_char(c) && c != '$' + }; + if !is_valid { + return None; + } + } + None + } + + /// A Python DB-API placeholder (`%s` or `%(name)s`). + fn is_python_placeholder(&mut self) -> bool { + let rest = &self.text[self.pos + 1..]; + let len = if let Some(after) = rest.strip_prefix('(') { + match after.find(')') { + Some(index) if after[..index].chars().all(is_ident_char) && after[index + 1..].starts_with(['s', 'd']) => { + index + 3 + } + _ => return false, + } + } else if rest.starts_with('s') { + 1 + } else { + return false; + }; + if rest[len..].chars().next().is_some_and(is_ident_char) { + return false; + } + self.pos += 1 + len; + true + } + + // ---- words, numbers and operators ---- + + fn word(&mut self) -> Result { + let start = self.pos; + self.eat_ident_chars(); + let word = &self.text[start..self.pos]; + let next = self.peek(); + + if next == Some('-') && self.dialect == Dialect::BigQuery { + self.eat_bigquery_dashed_name(); + if self.pos > start + word.len() { + return Ok(TokenKind::Word); + } + } + // a file's URL in a Snowflake `PUT` or `GET` (ex. `file:///tmp/data.csv`), whose `//` would + // otherwise start a comment + if self.dialect == Dialect::Snowflake && word.eq_ignore_ascii_case("file") && self.rest().starts_with("://") { + self.eat_until_space(); + return Ok(TokenKind::Word); + } + + // a prefix written directly against the quote of the string or identifier it modifies + if next == Some('\'') { + let prefix_len = word.len() + 1; + if word.eq_ignore_ascii_case("q") || word.eq_ignore_ascii_case("nq") { + if matches!(self.dialect, Dialect::Oracle | Dialect::Generic) { + self.pos = start; + return self.oracle_quoted_string(word.len()); + } + } else if word.eq_ignore_ascii_case("e") { + self.pos = start; + return self.string(prefix_len, true); + } else if ["n", "x", "b"].iter().any(|prefix| word.eq_ignore_ascii_case(prefix)) { + self.pos = start; + return self.string(prefix_len, self.backslash_escapes()); + } else if self.dialect == Dialect::BigQuery && is_bigquery_string_prefix(word) { + // a backslash in a raw string isn't an escape, but still keeps the quote after it from + // ending the string (ex. `r'\''`) + self.pos = start; + return self.string(prefix_len, true); + } + } else if next == Some('"') && self.dialect == Dialect::BigQuery && is_bigquery_string_prefix(word) { + if self.rest().starts_with("\"\"\"") { + return self.triple_quoted(start, self.pos, "\"\"\""); + } + return self.quoted_from(start, '"', TokenKind::String, true); + } else if next == Some('&') && word.eq_ignore_ascii_case("u") { + // a unicode escaped string or identifier (`U&'..'` or `U&".."`) + match self.peek_at(1) { + Some('\'') => { + self.pos += 1; + return self.quoted_from(start, '\'', TokenKind::String, false); + } + Some('"') => { + self.pos += 1; + return self.quoted_from(start, '"', TokenKind::QuotedIdent, false); + } + _ => {} + } + } + Ok(TokenKind::Word) + } + + /// The rest of a BigQuery name holding dashes (ex. `my-project.dataset.table`), which is only + /// taken as a name where it's in the path of a table, since `a-b` is otherwise a subtraction. + fn eat_bigquery_dashed_name(&mut self) { + let mut end = self.pos; + while self.text[end..].starts_with('-') + && self.text[end + 1..].starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_') + { + end += 1; + end += self.text[end..] + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(self.text.len() - end); + } + if end == self.pos { + return; + } + let rest = &self.text[end..]; + let is_name = (rest.starts_with('.') && rest[1..].starts_with(is_ident_start)) + || self.last_significant == Some(TokenKind::Dot) + || self + .tokens + .iter() + .rev() + .find(|token| !token.is_comment()) + .is_some_and(|token| { + ["FROM", "JOIN", "INTO", "TABLE", "UPDATE"] + .iter() + .any(|word| token.is_word(word)) + }); + if is_name { + self.pos = end; + } + } + + fn eat_until_space(&mut self) { + self.pos += self + .rest() + .find(|c: char| c.is_ascii_whitespace()) + .unwrap_or(self.rest().len()); + } + + fn eat_ident_chars(&mut self) { + let allows_hash = matches!(self.dialect, Dialect::SqlServer | Dialect::Oracle); + while let Some(c) = self.peek() { + // the MySQL client ends a statement at its delimiter wherever it appears outside of a + // string or comment, so `END$$` is `END` followed by the delimiter `$$` + if self + .delimiter + .is_some_and(|delimiter| self.rest().starts_with(delimiter)) + { + break; + } + if is_ident_char(c) || (allows_hash && c == '#') { + self.pos += c.len_utf8(); + } else { + break; + } + } + } + + fn eat_variable_chars(&mut self) { + while let Some(c) = self.peek() { + // `#` starts a comment in MySQL and BigQuery, and in the generic dialect when whitespace + // follows it + let allows_hash = match self.dialect { + Dialect::SqlServer | Dialect::Oracle => true, + Dialect::Generic => self.peek_at(1).is_some_and(is_ident_char), + _ => false, + }; + if is_ident_char(c) || (allows_hash && c == '#') { + self.pos += c.len_utf8(); + } else { + break; + } + } + } + + fn number(&mut self) -> TokenKind { + let bytes = self.text.as_bytes(); + let start = self.pos; + let radix_digits = match (bytes.get(start), bytes.get(start + 1).map(|b| b.to_ascii_lowercase())) { + (Some(b'0'), Some(b'x')) => Some(16), + (Some(b'0'), Some(b'b')) => Some(2), + (Some(b'0'), Some(b'o')) => Some(8), + _ => None, + }; + if let Some(radix) = radix_digits + && bytes.get(start + 2).is_some_and(|b| (*b as char).is_digit(radix)) + { + self.pos += 2; + while self.peek().is_some_and(|c| c.is_digit(radix) || c == '_') { + self.pos += 1; + } + } else { + self.eat_digits(); + // `1..10` is a range in PL/pgSQL and PL/SQL, not a number followed by a fraction + if self.peek() == Some('.') && self.peek_at(1) != Some('.') { + self.pos += 1; + self.eat_digits(); + } + self.eat_exponent(); + } + // a number running into letters is a word (ex. MySQL allows identifiers like `1st`) + if self.peek().is_some_and(|c| c.is_alphabetic() || c == '_') { + self.eat_ident_chars(); + return TokenKind::Word; + } + TokenKind::Number + } + + fn eat_digits(&mut self) { + while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '_') { + self.pos += 1; + } + } + + fn eat_exponent(&mut self) { + if matches!(self.peek(), Some('e' | 'E')) { + let sign_len = usize::from(matches!(self.peek_at(1), Some('+' | '-'))); + if self.peek_at(1 + sign_len).is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1 + sign_len; + self.eat_digits(); + } + } + } + + fn dot(&mut self) -> TokenKind { + if self.peek_at(1) == Some('.') { + self.pos += 2; + return TokenKind::Operator; + } + let follows_value = !self.space + && self.newlines == 0 + && self.tokens.last().is_some_and(|token| { + matches!( + token.kind, + TokenKind::Word | TokenKind::QuotedIdent | TokenKind::CloseParen | TokenKind::Variable + ) + }); + if !follows_value && self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1; + self.eat_digits(); + self.eat_exponent(); + return TokenKind::Number; + } + self.single(TokenKind::Dot) + } + + fn operator(&mut self) -> TokenKind { + let start = self.pos; + let mut end = start; + for (i, c) in self.text[start..].char_indices() { + let at = start + i; + let is_part = is_operator_char(c) + && !(i > 0 && self.starts_something_else(at, c)) + && (c != '#' || i == 0 || matches!(self.dialect, Dialect::PostgreSql | Dialect::Generic)) + && (c != '?' || i == 0 || self.dialect == Dialect::PostgreSql); + if !is_part { + break; + } + end = at + c.len_utf8(); + } + if end == start { + end = start + self.peek().unwrap().len_utf8(); + } + // like PostgreSQL, an operator can't end in `+` or `-` unless it holds one of the characters + // that only operators use, so `=-1` is `=` followed by `-1`, and no other dialect has an + // operator ending in either + let mut text = &self.text[start..end]; + let is_postgres = matches!(self.dialect, Dialect::PostgreSql | Dialect::Generic); + if !is_postgres || !text.contains(['~', '!', '@', '#', '%', '^', '&', '|', '`', '?']) { + while text.len() > 1 && text.ends_with(['+', '-']) { + text = &text[..text.len() - 1]; + } + } + self.pos = start + text.len(); + TokenKind::Operator + } + + /// Whether the operator character `c` at `at` starts a token of its own rather than continuing + /// the operator before it. + fn starts_something_else(&self, at: usize, c: char) -> bool { + let next = self.text[at + c.len_utf8()..].chars().next(); + match c { + '-' => next == Some('-'), + '/' => next == Some('*') || (next == Some('/') && self.dialect == Dialect::Snowflake), + '@' => self.dialect != Dialect::PostgreSql && next.is_some_and(|c| c == '@' || is_ident_start(c)), + _ => false, + } + } + + fn single(&mut self, kind: TokenKind) -> TokenKind { + self.pos += self.peek().unwrap().len_utf8(); + kind + } + + // ---- helpers ---- + + fn rest(&self) -> &'a str { + &self.text[self.pos..] + } + + fn rest_of_line(&self) -> &'a str { + let rest = self.rest(); + match rest.find('\n') { + Some(index) => &rest[..index], + None => rest, + } + } + + fn peek(&self) -> Option { + self.text[self.pos..].chars().next() + } + + fn peek_at(&self, offset: usize) -> Option { + self.text[self.pos..].chars().nth(offset) + } + + fn error(&self, start: usize, message: &str) -> LexError { + LexError { + message: message.to_string(), + span: start..start + self.text[start..].chars().next().map(|c| c.len_utf8()).unwrap_or(0), + } + } +} + +/// Whether the tokens are a `COPY .. FROM STDIN`. +fn is_copy_from_stdin(tokens: &[Token]) -> bool { + let mut words = tokens.iter().filter(|token| !token.is_comment()); + if !words.next().is_some_and(|token| token.is_word("COPY")) { + return false; + } + let mut after_from = false; + for token in words { + if token.is_word("FROM") { + after_from = true; + } else if after_from { + return token.is_word("STDIN"); + } + } + false +} + +fn find_nested_comment_end(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut depth = 0; + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'/' && bytes[i + 1] == b'*' { + depth += 1; + i += 2; + } else if bytes[i] == b'*' && bytes[i + 1] == b'/' { + depth -= 1; + i += 2; + if depth == 0 { + return Some(i); + } + } else { + i += 1; + } + } + None +} + +fn is_bigquery_string_prefix(word: &str) -> bool { + ["r", "b", "rb", "br"] + .iter() + .any(|prefix| word.eq_ignore_ascii_case(prefix)) +} + +fn is_sqlcmd_command(line: &str) -> bool { + const COMMANDS: &[&str] = &[ + "setvar", + "r", + "on", + "connect", + "out", + "error", + "exit", + "quit", + "listvar", + "serverlist", + "xml", + "help", + "reset", + "ed", + "perftrace", + ]; + let Some(rest) = line.strip_prefix(':') else { + return false; + }; + let word_len = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len()); + let word = &rest[..word_len]; + word_len > 0 + && rest[word_len..].chars().next().is_none_or(is_space) + && COMMANDS.iter().any(|c| c.eq_ignore_ascii_case(word)) +} + +fn is_sql_plus_command(first_word: &str, rest: &str) -> bool { + // only the commands whose names are unlikely to start a line of SQL + const COMMANDS: &[&str] = &[ + "prompt", "rem", "remark", "spool", "whenever", "define", "undefine", "accept", "ttitle", "btitle", "show", + "column", "col", "desc", "describe", "conn", "connect", "var", "variable", "print", "host", "clear", "compute", + "pause", + ]; + const SET_OPTIONS: &[&str] = &[ + "serveroutput", + "define", + "echo", + "feedback", + "linesize", + "lines", + "pagesize", + "pages", + "verify", + "timing", + "heading", + "termout", + "trimspool", + "trimout", + "sqlblanklines", + "autocommit", + "colsep", + "long", + "numwidth", + "tab", + "wrap", + "arraysize", + "scan", + "concat", + "escape", + "markup", + "newpage", + "underline", + ]; + if first_word.starts_with('@') { + return true; + } + if first_word.eq_ignore_ascii_case("set") { + let option = rest.split_whitespace().next().unwrap_or(""); + return SET_OPTIONS.iter().any(|o| o.eq_ignore_ascii_case(option)); + } + // `EXEC p` runs a procedure, while `EXECUTE IMMEDIATE` is only in PL/SQL + if first_word.eq_ignore_ascii_case("exec") || first_word.eq_ignore_ascii_case("execute") { + return !rest + .split_whitespace() + .next() + .is_some_and(|word| word.eq_ignore_ascii_case("immediate")); + } + COMMANDS.iter().any(|c| c.eq_ignore_ascii_case(first_word)) +} + +/// Whether `c` can start an identifier. Whitespace beyond ASCII is part of an identifier in +/// PostgreSQL and MySQL, so it's kept with the text around it rather than taken as a space. +pub fn is_ident_start(c: char) -> bool { + c.is_alphabetic() || c == '_' || is_unicode_space(c) +} + +pub fn is_ident_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '$' || is_unicode_space(c) +} + +fn is_unicode_space(c: char) -> bool { + !c.is_ascii() && c.is_whitespace() +} + +/// Whether `c` is whitespace between tokens, which is only ever ASCII. +pub fn is_space(c: char) -> bool { + matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c') +} + +fn is_operator_char(c: char) -> bool { + matches!( + c, + '+' | '-' | '*' | '/' | '<' | '>' | '=' | '~' | '!' | '@' | '#' | '%' | '^' | '&' | '|' | '?' + ) +} + +#[cfg(test)] +mod test { + use super::*; + + fn tokens(text: &str, dialect: Dialect) -> Vec<(TokenKind, &str)> { + lex(text, dialect) + .unwrap() + .into_iter() + .map(|t| (t.kind, t.text)) + .collect() + } + + fn texts(text: &str, dialect: Dialect) -> Vec<&str> { + tokens(text, dialect).into_iter().map(|(_, text)| text).collect() + } + + use TokenKind::*; + + #[test] + fn lexes_basic_select() { + assert_eq!( + tokens("SELECT a, b.c FROM t WHERE x >= 1.5;", Dialect::Generic), + vec![ + (Word, "SELECT"), + (Word, "a"), + (Comma, ","), + (Word, "b"), + (Dot, "."), + (Word, "c"), + (Word, "FROM"), + (Word, "t"), + (Word, "WHERE"), + (Word, "x"), + (Operator, ">="), + (Number, "1.5"), + (Semicolon, ";"), + ] + ); + } + + #[test] + fn records_whitespace_before_tokens() { + let tokens = lex("a b\n\nc(d)", Dialect::Generic).unwrap(); + assert_eq!( + tokens + .iter() + .map(|t| (t.space_before, t.newlines_before)) + .collect::>(), + vec![(false, 0), (true, 0), (true, 2), (false, 0), (false, 0), (false, 0)] + ); + } + + #[test] + fn lexes_strings() { + assert_eq!( + texts("'it''s' N'x' E'a\\'b' X'FF' B'01'", Dialect::Generic), + vec!["'it''s'", "N'x'", "E'a\\'b'", "X'FF'", "B'01'"] + ); + // backslash escapes depend on the dialect + assert_eq!(texts(r"'a\' b", Dialect::Generic), vec![r"'a\'", "b"]); + assert_eq!(texts(r"'a\'b'", Dialect::MySql), vec![r"'a\'b'"]); + assert_eq!(texts(r#""a\"b""#, Dialect::MySql), vec![r#""a\"b""#]); + assert_eq!(texts(r"'C:\'", Dialect::SqlServer), vec![r"'C:\'"]); + assert_eq!( + texts("U&'d\\0061t' U&\"x\"", Dialect::PostgreSql), + vec!["U&'d\\0061t'", "U&\"x\""] + ); + assert_eq!(texts("'multi\nline'", Dialect::Generic), vec!["'multi\nline'"]); + } + + #[test] + fn lexes_oracle_quoted_strings() { + assert_eq!( + texts("q'[it's]' Q'{a}b}' q'!x!' nq'(y)'", Dialect::Oracle), + vec!["q'[it's]'", "Q'{a}b}'", "q'!x!'", "nq'(y)'"] + ); + } + + #[test] + fn lexes_bigquery_strings() { + assert_eq!( + texts("r'\\d' b\"x\" '''a\n'b'''", Dialect::BigQuery), + vec!["r'\\d'", "b\"x\"", "'''a\n'b'''"] + ); + assert_eq!(tokens("\"text\"", Dialect::BigQuery), vec![(String, "\"text\"")]); + } + + #[test] + fn lexes_dollar_strings() { + assert_eq!( + tokens("$$a $b$ ;$$ $fn$x$$y$fn$ $1", Dialect::PostgreSql), + vec![ + (DollarString, "$$a $b$ ;$$"), + (DollarString, "$fn$x$$y$fn$"), + (Variable, "$1") + ] + ); + assert_eq!( + tokens("$action $1", Dialect::SqlServer), + vec![(Variable, "$action"), (Variable, "$1")] + ); + assert!(lex("$$ unterminated", Dialect::PostgreSql).is_err()); + } + + #[test] + fn lexes_quoted_identifiers() { + assert_eq!( + tokens("\"a\"\"b\" `c``d` [e]]f]", Dialect::SqlServer), + vec![ + (QuotedIdent, "\"a\"\"b\""), + (QuotedIdent, "`c``d`"), + (QuotedIdent, "[e]]f]") + ] + ); + } + + #[test] + fn lexes_brackets_by_dialect() { + assert_eq!(texts("arr[1]", Dialect::PostgreSql), vec!["arr", "[", "1", "]"]); + assert_eq!(texts("[My Table]", Dialect::SqlServer), vec!["[My Table]"]); + // the generic dialect tells them apart by what comes before + assert_eq!( + texts("arr[1] ARRAY[1, 2]", Dialect::Generic), + vec!["arr", "[", "1", "]", "ARRAY", "[", "1", ",", "2", "]"] + ); + assert_eq!( + texts("SELECT [a], [b] FROM [dbo].[t]", Dialect::Generic), + vec!["SELECT", "[a]", ",", "[b]", "FROM", "[dbo]", ".", "[t]"] + ); + } + + #[test] + fn lexes_comments() { + assert_eq!( + tokens("a -- c\r\nb /* x /* y */ z */ c", Dialect::PostgreSql), + vec![ + (Word, "a"), + (LineComment, "-- c"), + (Word, "b"), + (BlockComment, "/* x /* y */ z */"), + (Word, "c") + ] + ); + // MySQL comments don't nest + assert_eq!(texts("/* x /* y */ z", Dialect::MySql), vec!["/* x /* y */", "z"]); + // the generic dialect falls back to not nesting when that's the only way the comment ends + assert_eq!(texts("/* x /* y */ z", Dialect::Generic), vec!["/* x /* y */", "z"]); + assert!(lex("/* x", Dialect::Generic).is_err()); + } + + #[test] + fn lexes_double_dash_in_mysql() { + assert_eq!(texts("a--b", Dialect::MySql), vec!["a", "-", "-", "b"]); + assert_eq!(texts("a-- b", Dialect::MySql), vec!["a", "-- b"]); + assert_eq!(texts("a--", Dialect::MySql), vec!["a", "--"]); + } + + #[test] + fn lexes_hash_by_dialect() { + assert_eq!(tokens("# c", Dialect::MySql), vec![(LineComment, "# c")]); + assert_eq!(tokens("SELECT * FROM #temp", Dialect::SqlServer)[3], (Word, "#temp")); + assert_eq!(tokens("SELECT * FROM ##temp", Dialect::SqlServer)[3], (Word, "##temp")); + assert_eq!(tokens("a #> b", Dialect::PostgreSql)[1], (Operator, "#>")); + assert_eq!(tokens("a # b", Dialect::PostgreSql)[1], (Operator, "#")); + assert_eq!(tokens("#comment", Dialect::Generic), vec![(LineComment, "#comment")]); + assert_eq!(tokens("# comment", Dialect::Generic), vec![(LineComment, "# comment")]); + assert_eq!(tokens("FROM #temp", Dialect::Generic)[1], (Word, "#temp")); + assert_eq!(tokens("FROM\n#temp", Dialect::Generic)[1], (Word, "#temp")); + assert_eq!(tokens("SELECT 1;\n#temp", Dialect::Generic)[3], (LineComment, "#temp")); + assert_eq!(tokens("USE db; #inline", Dialect::Generic)[3], (LineComment, "#inline")); + assert_eq!(tokens("a #%# b", Dialect::Generic)[1], (Operator, "#%#")); + assert_eq!( + tokens("#=== section ===", Dialect::Generic)[0], + (LineComment, "#=== section ===") + ); + } + + #[test] + fn lexes_variables() { + assert_eq!( + tokens("@a @@ROWCOUNT :b :1 ? ?1 ${x} %s %(name)s", Dialect::Generic), + vec![ + (Variable, "@a"), + (Variable, "@@ROWCOUNT"), + (Variable, ":b"), + (Variable, ":1"), + (Variable, "?"), + (Variable, "?1"), + (Variable, "${x}"), + (Variable, "%s"), + (Variable, "%(name)s"), + ] + ); + assert_eq!(texts("@'my var'", Dialect::MySql), vec!["@'my var'"]); + assert_eq!(texts(":'v' :\"v\"", Dialect::PostgreSql), vec![":'v'", ":\"v\""]); + assert_eq!(texts("&x &&y", Dialect::Oracle), vec!["&x", "&&y"]); + } + + #[test] + fn lexes_operators() { + assert_eq!(texts("a=-1", Dialect::Generic), vec!["a", "=", "-", "1"]); + assert_eq!( + texts("a<>b a!=b a<=>b a||b a::int a:=1 a=>b", Dialect::Generic), + vec![ + "a", "<>", "b", "a", "!=", "b", "a", "<=>", "b", "a", "||", "b", "a", "::", "int", "a", ":=", "1", "a", "=>", + "b" + ] + ); + assert_eq!( + texts("j->>'a' j#>>'{a}' j @> k j ?| k", Dialect::PostgreSql), + vec!["j", "->>", "'a'", "j", "#>>", "'{a}'", "j", "@>", "k", "j", "?|", "k"] + ); + assert_eq!(texts("a=?", Dialect::Generic), vec!["a", "=", "?"]); + assert_eq!( + tokens("a<@ARRAY[1] AND b@@c AND d?'k' AND e = ?", Dialect::PostgreSql), + vec![ + (Word, "a"), + (Operator, "<@"), + (Word, "ARRAY"), + (OpenBracket, "["), + (Number, "1"), + (CloseBracket, "]"), + (Word, "AND"), + (Word, "b"), + (Operator, "@@"), + (Word, "c"), + (Word, "AND"), + (Word, "d"), + (Operator, "?"), + (String, "'k'"), + (Word, "AND"), + (Word, "e"), + (Operator, "="), + (Variable, "?"), + ] + ); + assert_eq!(texts("a=@b", Dialect::SqlServer), vec!["a", "=", "@b"]); + assert_eq!(texts("a*-b", Dialect::Generic), vec!["a", "*", "-", "b"]); + assert_eq!(texts("1--x\n2", Dialect::Generic), vec!["1", "--x", "2"]); + assert_eq!(texts("SET @x *= 2", Dialect::SqlServer), vec!["SET", "@x", "*=", "2"]); + } + + #[test] + fn lexes_numbers() { + assert_eq!( + tokens("1 1.5 .5 1e10 1.5E-3 0xFF 1_000 1..10", Dialect::Generic), + vec![ + (Number, "1"), + (Number, "1.5"), + (Number, ".5"), + (Number, "1e10"), + (Number, "1.5E-3"), + (Number, "0xFF"), + (Number, "1_000"), + (Number, "1"), + (Operator, ".."), + (Number, "10"), + ] + ); + assert_eq!( + tokens("1st t.5", Dialect::MySql), + vec![(Word, "1st"), (Word, "t"), (Dot, "."), (Number, "5")] + ); + } + + #[test] + fn lexes_templates() { + assert_eq!( + tokens("{{ ref('x') }} {% if a %} {# c #} {fn now()}", Dialect::Generic), + vec![ + (Template, "{{ ref('x') }}"), + (Template, "{% if a %}"), + (Template, "{# c #}"), + (OpenBrace, "{"), + (Word, "fn"), + (Word, "now"), + (OpenParen, "("), + (CloseParen, ")"), + (CloseBrace, "}"), + ] + ); + } + + #[test] + fn lexes_template_tags_on_their_own_lines() { + assert_eq!( + tokens("SELECT 1\n {% if x %}\nUNION {% endif %}\n{# c #}", Dialect::Generic), + vec![ + (Word, "SELECT"), + (Number, "1"), + (TemplateLine, "{% if x %}"), + (Word, "UNION"), + (Template, "{% endif %}"), + (TemplateLine, "{# c #}"), + ] + ); + // an expression is never on a line of its own + assert_eq!( + tokens("{{ config(x=1) }}\nSELECT 1", Dialect::Generic)[0], + (Template, "{{ config(x=1) }}") + ); + } + + #[test] + fn lexes_batch_separators() { + assert_eq!( + tokens("SELECT 1\nGO\nSELECT 2\ngo 5 -- c", Dialect::SqlServer), + vec![ + (Word, "SELECT"), + (Number, "1"), + (BatchSeparator, "GO"), + (Word, "SELECT"), + (Number, "2"), + (BatchSeparator, "go 5"), + (LineComment, "-- c"), + ] + ); + // `GO` is only a separator on a line of its own, or after a `;` at the end of a line + assert_eq!(tokens("SELECT go FROM t", Dialect::SqlServer)[1], (Word, "go")); + assert_eq!( + tokens("SELECT 1; GO\nSELECT 2", Dialect::SqlServer)[3], + (BatchSeparator, "GO") + ); + assert_eq!( + tokens("SELECT 1; GO -- c\nSELECT 2", Dialect::SqlServer)[3], + (BatchSeparator, "GO") + ); + assert_eq!(tokens("SELECT 1; go_on", Dialect::SqlServer)[3], (Word, "go_on")); + assert_eq!( + tokens("BEGIN NULL; END;\n/\n", Dialect::Oracle)[5], + (BatchSeparator, "/") + ); + assert_eq!(tokens("a\n/ b", Dialect::Oracle)[1], (Operator, "/")); + } + + #[test] + fn lexes_delimiter_command() { + assert_eq!( + tokens( + "DELIMITER //\nCREATE PROCEDURE p() BEGIN SELECT 1; END //\nDELIMITER ;\nSELECT 1;", + Dialect::MySql + ), + vec![ + (Directive, "DELIMITER //"), + (Word, "CREATE"), + (Word, "PROCEDURE"), + (Word, "p"), + (OpenParen, "("), + (CloseParen, ")"), + (Word, "BEGIN"), + (Word, "SELECT"), + (Number, "1"), + (Semicolon, ";"), + (Word, "END"), + (Delimiter, "//"), + (Directive, "DELIMITER ;"), + (Word, "SELECT"), + (Number, "1"), + (Semicolon, ";"), + ] + ); + assert_eq!( + tokens("DELIMITER $$\nSELECT 1$$", Dialect::Generic)[3], + (Delimiter, "$$") + ); + // a column named delimiter isn't a command + assert_eq!( + tokens("CREATE TABLE t (\ndelimiter VARCHAR(10)\n)", Dialect::MySql)[4], + (Word, "delimiter") + ); + assert_eq!(tokens("delimiter text", Dialect::Generic)[0], (Word, "delimiter")); + // nor is an option in the middle of a statement + assert_eq!( + tokens("COPY t FROM STDIN WITH (\n DELIMITER ';'\n);", Dialect::Generic)[6], + (Word, "DELIMITER") + ); + } + + #[test] + fn lexes_client_commands() { + assert_eq!( + tokens("\\connect db\nSELECT 1;", Dialect::PostgreSql)[0], + (Directive, "\\connect db") + ); + // a psql meta-command runs to the end of its line wherever it starts + assert_eq!( + texts("SELECT $1 \\bind 'v' \\g\n\\endpipeline", Dialect::PostgreSql), + vec!["SELECT", "$1", "\\bind 'v' \\g", "\\endpipeline"] + ); + assert_eq!( + tokens(":setvar x 1\nSELECT 1", Dialect::SqlServer)[0], + (Directive, ":setvar x 1") + ); + assert_eq!( + tokens("PROMPT don't stop\nSELECT 1 FROM dual;", Dialect::Oracle)[0], + (Directive, "PROMPT don't stop") + ); + assert_eq!( + tokens("SET SERVEROUTPUT ON\nBEGIN NULL; END;", Dialect::Oracle)[0], + (Directive, "SET SERVEROUTPUT ON") + ); + assert_eq!(tokens("@script.sql", Dialect::Oracle)[0], (Directive, "@script.sql")); + assert_eq!(tokens("SET x = 1;", Dialect::Oracle)[0], (Word, "SET")); + // only between statements + assert_eq!(tokens("UPDATE t\nSET define = 1;", Dialect::Oracle)[2], (Word, "SET")); + assert_eq!(tokens("CALL p(\n@x)", Dialect::Oracle)[3], (Variable, "@x")); + } + + #[test] + fn lexes_copy_data() { + let text = "COPY t (a, b) FROM stdin;\n1\t'x\n2\t)\n\\.\nSELECT 1;"; + assert_eq!( + tokens(text, Dialect::PostgreSql)[9..], + [ + (Semicolon, ";"), + (Directive, "1\t'x\n2\t)\n\\."), + (Word, "SELECT"), + (Number, "1"), + (Semicolon, ";"), + ] + ); + } + + #[test] + fn lexes_copy_data_of_several_statements() { + let text = "select 0\\; copy t from stdin\\; copy t from stdin\\; select 1; -- c\n1\n\\.\n2\n\\.\nselect 2;"; + let tokens = tokens(text, Dialect::PostgreSql); + assert_eq!(tokens.iter().filter(|(kind, _)| *kind == Directive).count(), 2); + assert_eq!( + tokens.iter().find(|(kind, _)| *kind == LineComment), + Some(&(LineComment, "-- c")) + ); + assert_eq!(tokens.last(), Some(&(Semicolon, ";"))); + } + + #[test] + fn lexes_psql_semicolon_escape() { + assert_eq!( + texts("copy t to stdout\\; select 1;", Dialect::PostgreSql), + vec!["copy", "t", "to", "stdout", "\\;", "select", "1", ";"] + ); + } + + #[test] + fn lexes_snowflake_double_slash_comments() { + assert_eq!( + tokens("a // c\nb", Dialect::Snowflake), + vec![(Word, "a"), (LineComment, "// c"), (Word, "b")] + ); + assert_eq!(tokens("a // b", Dialect::Generic)[1], (Operator, "//")); + } + + #[test] + fn reports_unterminated_tokens() { + for (text, dialect, message) in [ + ("'abc", Dialect::Generic, "unterminated string"), + ("\"abc", Dialect::Generic, "unterminated quoted identifier"), + ("[abc", Dialect::SqlServer, "unterminated quoted identifier"), + ("/* abc", Dialect::Generic, "unterminated block comment"), + ("{{ abc", Dialect::Generic, "unterminated template tag"), + ("q'[abc'", Dialect::Oracle, "unterminated string"), + ] { + let error = lex(text, dialect).unwrap_err(); + assert_eq!(error.message, message, "{text}"); + assert_eq!(error.span.start, 0); + } + } + + #[test] + fn is_lossless() { + let text = "SELECT a,\n 'x' -- c\n /* d */ FROM [t] ;\n"; + let tokens = lex(text, Dialect::SqlServer).unwrap(); + let mut rebuilt = std::string::String::new(); + let mut last_end = 0; + for token in &tokens { + assert!(text[last_end..token.start].trim().is_empty()); + rebuilt.push_str(&text[last_end..token.start]); + rebuilt.push_str(token.text); + last_end = token.end(); + } + rebuilt.push_str(&text[last_end..]); + assert_eq!(rebuilt, text); + } +} diff --git a/src/lib.rs b/src/lib.rs index e19e450..2117a98 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,21 @@ +mod ast; pub mod configuration; +mod error; +mod format_range; mod format_text; +mod generation; +mod keywords; +mod lexer; +mod parser; +pub use error::FormatError; +pub use error::ParseError; +pub use format_range::format_text_range; pub use format_text::format_text; +#[cfg(feature = "tracing")] +pub use format_text::trace_file; + #[cfg(feature = "wasm")] #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] mod wasm_plugin; diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..4ec8ae4 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,1510 @@ +// Builds the statement and block structure from the tokens. +// +// The parser is tolerant: it understands enough to know where statements, blocks and brackets +// begin and end, and leaves everything else to the generator as a run of tokens. Brackets have +// to balance, since nothing sensible can be done otherwise, but a construct it can't make sense +// of (ex. a `CASE` without an `END`) is read as plain tokens rather than reported, because the +// dialect it's written in may just use those words differently. + +use std::collections::HashSet; +use std::ops::Range; + +use crate::ast::*; +use crate::configuration::Dialect; +use crate::keywords; +use crate::lexer::Token; +use crate::lexer::TokenKind; + +#[derive(Debug)] +pub struct SyntaxError { + pub message: String, + pub span: Range, +} + +pub fn parse<'a>(tokens: &'a [Token<'a>], dialect: Dialect) -> Result, SyntaxError> { + let mut parser = Parser { + tokens, + pos: 0, + dialect, + depth: 0, + not_declarations: false, + failed_constructs: HashSet::new(), + failed_declarations: HashSet::new(), + }; + let list = parser.parse_list(Stops::default())?; + if let Some(token) = parser.peek() { + // only a closing bracket stops the top level list + return Err(parser.error(token, "unexpected closing bracket")); + } + Ok(list) +} + +/// How deeply brackets and blocks may nest before the input is refused. Each level is a few stack +/// frames, and running out of stack aborts the process rather than raising an error. +const MAX_DEPTH: usize = 128; + +/// Which words end the statement list or the statement being parsed, which depends on what +/// encloses them. +#[derive(Clone, Copy, Default)] +struct Stops { + /// Within a `BEGIN .. END` block or another construct ended by `END`. + end: bool, + /// Within the body of an `IF` or a procedural `CASE`, ended by `ELSE`. + else_: bool, + /// Within the body of a procedural `IF`, ended by `ELSIF` or `ELSEIF`. + elsif: bool, + /// Within an exception handler or a branch of a procedural `CASE`, ended by the next `WHEN`. + when: bool, + /// Within a block that may have an `EXCEPTION` section. + exception: bool, + /// Within the body of a `REPEAT` loop, ended by `UNTIL`. + until: bool, + /// Within a declaration section, ended by `BEGIN`. + begin: bool, + /// Within a declaration section, where `PROCEDURE` and `FUNCTION` start a routine, and which a + /// `DECLARE` ends, since one is never within another. + declarations: bool, +} + +struct Parser<'a> { + tokens: &'a [Token<'a>], + pos: usize, + dialect: Dialect, + depth: usize, + /// Set when a list of declarations reaches something that isn't one. + not_declarations: bool, + /// Where a block, loop or other construct was tried and turned out not to be one (ex. a `BEGIN` + /// without an `END`), which is the same wherever it's tried from. Trying again would read the + /// rest of the input again, for every construct that encloses it. + failed_constructs: HashSet, + /// Where a section of declarations was tried and turned out not to be one, for the same reason, + /// whether what's there aren't declarations or they aren't followed by a block. + failed_declarations: HashSet, +} + +impl<'a> Parser<'a> { + // ---- statement lists ---- + + fn parse_list(&mut self, stops: Stops) -> Result, SyntaxError> { + let mut items: Vec> = Vec::new(); + let mut ignore_next = false; + while let Some(token) = self.peek() { + if self.is_list_stop(token, stops) || is_closing(token) { + break; + } + let blank_line_before = !items.is_empty() && token.newlines_before >= 2; + let kind = match token.kind { + // a comment that's a statement of its own (ex. MySQL's `/*!40101 SET NAMES utf8 */;`) + TokenKind::BlockComment + if self.tokens.get(self.pos + 1).is_some_and(|next| { + matches!(next.kind, TokenKind::Semicolon | TokenKind::Delimiter) && next.newlines_before == 0 + }) => + { + self.pos += 1; + ItemKind::Statement(self.finish_statement(vec![Element::Token(token)], None)) + } + TokenKind::LineComment | TokenKind::BlockComment | TokenKind::TemplateLine => { + self.pos += 1; + if dprint_core::formatting::ir_helpers::text_has_dprint_ignore(token.text, "dprint-ignore") { + ignore_next = true; + } + ItemKind::Comment(token) + } + TokenKind::BatchSeparator | TokenKind::Directive => { + self.pos += 1; + ignore_next = false; + ItemKind::Command(token, self.take_same_line_comments()) + } + _ => { + if stops.declarations && !self.looks_like_declaration(self.pos) { + self.not_declarations = true; + break; + } + let mut statement = self.parse_statement(stops)?; + statement.ignored = std::mem::take(&mut ignore_next); + if statement.elements.is_empty() && statement.terminator.is_none() { + // nothing could be parsed here, which only a stop within a statement causes + break; + } + ItemKind::Statement(statement) + } + }; + items.push(Item { + kind, + blank_line_before, + }); + } + Ok(StatementList { items }) + } + + fn is_list_stop(&self, token: &Token, stops: Stops) -> bool { + token.kind == TokenKind::Word + && ((stops.end && token.is_word("END")) + || (stops.else_ && token.is_word("ELSE")) + || (stops.elsif && (token.is_word("ELSIF") || token.is_word("ELSEIF"))) + || (stops.when && token.is_word("WHEN")) + || (stops.exception && token.is_word("EXCEPTION")) + || (stops.until && token.is_word("UNTIL")) + || (stops.begin && token.is_word("BEGIN")) + || (stops.declarations && token.is_word("DECLARE"))) + } + + // ---- statements ---- + + fn parse_statement(&mut self, stops: Stops) -> Result, SyntaxError> { + let mut elements = Vec::new(); + // comments before a statement that isn't in a list, such as the body of a T-SQL `IF` + while let Some(token) = self.peek().filter(|token| token.is_comment()) { + elements.push(Element::Token(token)); + self.pos += 1; + } + + if let Some(label) = self.parse_label() { + let standalone = !label.own_line + && self + .peek() + .is_none_or(|token| token.starts_line() || !self.is_labelable(token)); + elements.push(Element::Label(Box::new(label))); + if standalone { + // a T-SQL label for `GOTO`, which is a statement of its own + return Ok(self.finish_statement(elements, None)); + } + } + + if let Some(element) = self.parse_statement_construct(stops)? { + elements.push(element); + return Ok(self.finish_statement(elements, None)); + } + + let mut state = StatementState::default(); + if stops.declarations { + state.declarations = true; + } + if let Some(section) = self.parse_declare_section_at_start()? { + let element = Element::DeclareSection(Box::new(section)); + state.update(&element); + elements.push(element); + } + + let mut terminator = None; + while let Some(token) = self.peek() { + if is_terminator(token) { + terminator = Some(token); + self.pos += 1; + break; + } + match token.kind { + TokenKind::BatchSeparator | TokenKind::Directive => break, + _ if is_closing(token) => break, + _ => {} + } + if token.is_comment() { + // a comment at the end of a line belongs to what's before it, but one on a line of its + // own belongs to whatever follows it, which may be the next statement + if token.newlines_before > 0 && !elements.is_empty() { + let next = self.peek_past_comments(); + if next.is_none_or(|next| self.ends_statement(next, stops, &state, true)) { + break; + } + } + elements.push(Element::Token(token)); + self.pos += 1; + continue; + } + if !elements.is_empty() && self.ends_statement(token, stops, &state, false) { + break; + } + let element = self.parse_element(&state)?; + let ends = matches!( + element, + Element::Block(_) | Element::If(_) | Element::Conditional(_) | Element::Loop(_) | Element::CaseStatement(_) + ); + state.update(&element); + let is_routine_body_start = state.routine + && element + .as_token() + .is_some_and(|token| token.is_word("IS") || token.is_word("AS")); + elements.push(element); + if ends { + break; + } + if is_routine_body_start && let Some(section) = self.parse_routine_declarations()? { + let element = Element::DeclareSection(Box::new(section)); + state.update(&element); + elements.push(element); + } + } + Ok(self.finish_statement(elements, terminator)) + } + + fn finish_statement(&mut self, elements: Vec>, mut terminator: Option>) -> Statement<'a> { + if terminator.is_none() + && let Some(token) = self.peek().filter(|token| is_terminator(token)) + { + terminator = Some(token); + self.pos += 1; + } + let trailing_comments = if terminator.is_some() || elements.last().is_some_and(|element| element.is_procedural()) { + self.take_same_line_comments() + } else { + Vec::new() + }; + Statement { + elements, + terminator, + trailing_comments, + ignored: false, + } + } + + fn take_same_line_comments(&mut self) -> Vec> { + let mut comments = Vec::new(); + while let Some(token) = self + .peek() + .filter(|token| token.is_comment() && token.newlines_before == 0) + { + comments.push(token); + self.pos += 1; + } + comments + } + + /// Whether `token` ends the statement being parsed rather than continuing it. + fn ends_statement(&self, token: &Token, stops: Stops, state: &StatementState, is_after_comment: bool) -> bool { + match token.kind { + // the terminator ends the statement the comment before it is in + TokenKind::Semicolon | TokenKind::Delimiter => return false, + TokenKind::BatchSeparator | TokenKind::Directive => return true, + _ if is_closing(token) => return true, + _ => {} + } + if token.kind != TokenKind::Word { + return false; + } + if (stops.end && token.is_word("END")) + || (stops.else_ && token.is_word("ELSE")) + || (stops.elsif && (token.is_word("ELSIF") || token.is_word("ELSEIF"))) + { + return true; + } + if is_after_comment && self.is_list_stop(token, stops) { + return true; + } + self.allows_statements_without_semicolons() && self.starts_new_statement(token, state) + } + + /// T-SQL doesn't need a `;` between statements, so a statement there may also end where the + /// next one begins. + fn allows_statements_without_semicolons(&self) -> bool { + matches!(self.dialect, Dialect::SqlServer | Dialect::Generic) + } + + /// Whether `token`, a word, starts a new statement in place of continuing the one before it, + /// which is only asked in the dialects that don't need a `;` between them. + fn starts_new_statement(&self, token: &Token, state: &StatementState) -> bool { + // the generic dialect only splits where a new line starts, since a mistake there is more + // likely, and the formatter writes everything that does start a line on one of its own + if self.dialect == Dialect::Generic && !token.starts_line() { + return false; + } + let next = self.tokens.get(self.pos + 1); + let next_is_call = next.is_some_and(|next| next.kind == TokenKind::OpenParen && !next.space_before); + let next_is = |word: &str| next.is_some_and(|next| next.is_word(word)); + let prev = state.prev_word.unwrap_or(""); + let prev_is = |words: &[&str]| words.iter().any(|word| word.eq_ignore_ascii_case(prev)); + let head = state.head.unwrap_or(""); + let head_is = |words: &[&str]| words.iter().any(|word| word.eq_ignore_ascii_case(head)); + if state.prev_is_comma_or_operator { + return false; + } + // the privileges of a `GRANT` are words that start statements (ex. `GRANT SELECT, UPDATE`) + if head_is(&["GRANT", "REVOKE", "DENY"]) && (!token.starts_line() || prev_is(&["GRANT", "REVOKE", "DENY"])) { + return false; + } + let word = token.text; + let is = |candidate: &str| word.eq_ignore_ascii_case(candidate); + + // the body of a T-SQL routine without a `BEGIN` is the rest of the batch, so the statements + // in it are written as statements of their own + if state.routine + && prev_is(&["AS"]) + && !is("BEGIN") + && !is("RETURN") + && !is("EXTERNAL") + && keywords::contains(keywords::TSQL_STATEMENT_KEYWORDS, word) + { + return true; + } + + if is("SELECT") || (is("WITH") && self.is_cte_start(self.pos)) { + // PostgreSQL's `ON CONFLICT .. DO SELECT` + if prev_is(&["UNION", "ALL", "DISTINCT", "INTERSECT", "EXCEPT", "MINUS", "DO"]) { + return false; + } + if state.query_started { + return true; + } + // a T-SQL `RETURN` that returns nothing (ex. `IF @x IS NULL RETURN`), unlike the one of + // `AS RETURN SELECT ..` + if head_is(&["RETURN"]) && prev_is(&["RETURN"]) && token.starts_line() { + return true; + } + if prev_is(&[ + "AS", "FOR", "IN", "QUERY", "EXPLAIN", "ANALYZE", "VERBOSE", "IS", "RETURN", + ]) { + return false; + } + // a `CREATE` or `ALTER` only has a query after its `AS`, which the check above covers + return !head_is(&["INSERT", "REPLACE", "WITH", "EXPLAIN", "DESCRIBE", "DESC", "UPSERT"]); + } + if is("INSERT") || is("UPDATE") || is("DELETE") || is("MERGE") || is("REPLACE") { + // a join hint (ex. `LEFT MERGE JOIN`) + if next_is_call || next_is("JOIN") { + return false; + } + if prev_is(&[ + "ON", "FOR", "KEY", "DO", "THEN", "BEFORE", "AFTER", "OF", "OR", "GRANT", "REVOKE", "DENY", "INSTEAD", "NO", + "ALSO", + ]) { + return false; + } + // the body of a trigger without a `BEGIN` (ex. `FOR EACH ROW INSERT ..`), or an action of + // an `ALTER` (ex. ClickHouse's `ALTER TABLE t UPDATE a = 1`) + if (head_is(&["CREATE"]) && state.routine) || head_is(&["ALTER"]) { + return false; + } + return !(head_is(&["WITH"]) && state.main_dml.is_none()); + } + if is("SET") { + if head_is(&["ALTER", "CREATE", "REPLACE", "GRANT", "REVOKE"]) || state.handler { + return false; + } + if prev_is(&["CHARACTER", "CHAR", "DELETE", "UPDATE", "NULL", "DEFAULT"]) { + return false; + } + // an `UPDATE` has one `SET`, a `MERGE` has one after each of its `UPDATE`s, and a MySQL + // `INSERT INTO t SET a = 1` has one before any values, so a `SET` after them starts the + // next statement (ex. `UPDATE t SET a = 1 SET @done = 1`) + let dml_is = |word: &str| state.main_dml.is_some_and(|dml| dml.eq_ignore_ascii_case(word)); + if dml_is("UPDATE") { + return state.update_set_seen; + } + if dml_is("INSERT") { + return state.query_started; + } + return !(head_is(&["WITH"]) && state.main_dml.is_none()); + } + if is("EXEC") || is("EXECUTE") { + if prev_is(&["WITH", "GRANT", "REVOKE", "DENY", "AS"]) || head_is(&["CREATE", "ALTER"]) { + return false; + } + return !head_is(&["INSERT"]) || state.query_started; + } + if is("IF") { + return !next_is_call && !keywords::contains(keywords::OBJECT_KINDS, prev) && !prev_is(&["EXISTS", "NOT"]); + } + if is("BEGIN") { + return !state.allows_block() && !head_is(&["CREATE", "ALTER"]); + } + if is("RETURN") { + // `RETURN` is the body of a SQL function in PostgreSQL and Databricks + return !prev_is(&["AS"]) && !state.routine; + } + if is("CREATE") || is("ALTER") || is("DROP") { + if prev_is(&["GRANT", "REVOKE", "DENY", "WITH", "ON", "OR"]) { + return false; + } + if head_is(&["ALTER"]) && (is("ALTER") || is("DROP")) { + // `ALTER TABLE t DROP COLUMN c` and `ALTER TABLE t ALTER COLUMN c ..` continue it, while + // a statement altering or dropping something else doesn't + return next.is_some_and(|next| { + next.kind == TokenKind::Word + && keywords::contains(keywords::OBJECT_KINDS, next.text) + && !next.is_word("COLUMN") + && !next.is_word("CONSTRAINT") + && !next.is_word("INDEX") + && !next.is_word("PARTITION") + && !next.is_word("KEY") + }); + } + return !next_is_call; + } + if is("FETCH") { + return !(state.query_started || prev_is(&["ROW", "ROWS", "ONLY"])); + } + if is("USE") { + return !(next_is("INDEX") || next_is("KEY") || next_is("NONE")); + } + if is("COMMIT") || is("ROLLBACK") { + return !prev_is(&["ON", "WITH", "PRESERVE", "DELETE"]); + } + if is("GRANT") || is("REVOKE") || is("DENY") { + return !prev_is(&["WITH"]); + } + if is("WHILE") + || is("DECLARE") + || is("PRINT") + || is("RAISERROR") + || is("THROW") + || is("WAITFOR") + || is("GOTO") + || is("DEALLOCATE") + || is("TRUNCATE") + || is("OPEN") + || is("CLOSE") + || is("BREAK") + || is("CONTINUE") + || is("SAVE") + || is("DBCC") + || is("BACKUP") + || is("RESTORE") + || is("CHECKPOINT") + || is("KILL") + || is("RECONFIGURE") + { + // an action of an `ALTER TABLE` (ex. MySQL's `TRUNCATE PARTITION`) + if head_is(&["ALTER"]) { + return false; + } + return !next_is_call && !prev_is(&["GRANT", "REVOKE", "DENY"]); + } + false + } + + /// Whether the `WITH` at `index` begins common table expressions (`WITH name AS (` or + /// `WITH RECURSIVE`) rather than a table hint or an option. + fn is_cte_start(&self, index: usize) -> bool { + let next = |offset: usize| { + self.tokens[index + 1..] + .iter() + .filter(|token| !token.is_comment()) + .nth(offset) + }; + match next(0) { + Some(token) if token.is_word("RECURSIVE") => true, + Some(token) if matches!(token.kind, TokenKind::Word | TokenKind::QuotedIdent) => match next(1) { + // not an option of a view or a routine (ex. `WITH SCHEMABINDING AS SELECT ..`) + Some(token) if token.is_word("AS") => next(2).is_some_and(|token| { + token.kind == TokenKind::OpenParen || token.is_word("MATERIALIZED") || token.is_word("NOT") + }), + // `WITH name (columns) AS (` + Some(token) if token.kind == TokenKind::OpenParen => { + let mut depth = 0; + for (i, token) in self.tokens[index + 1..].iter().enumerate() { + match token.kind { + TokenKind::OpenParen => depth += 1, + TokenKind::CloseParen => { + depth -= 1; + if depth == 0 { + return self + .tokens + .get(index + 1 + i + 1) + .is_some_and(|token| token.is_word("AS")); + } + } + _ => {} + } + } + false + } + _ => false, + }, + _ => false, + } + } + + // ---- statement level constructs ---- + + /// Parses procedural code that makes up a whole statement (ex. a block or an `IF`), if that's + /// what starts here. + fn parse_statement_construct(&mut self, stops: Stops) -> Result>, SyntaxError> { + let Some(token) = self.peek() else { + return Ok(None); + }; + if token.kind != TokenKind::Word { + return Ok(None); + } + let start = self.pos; + if self.failed_constructs.contains(&start) { + return Ok(None); + } + let result = if token.is_word("BEGIN") && self.is_block_begin(self.pos) { + self.parse_block()?.map(|block| Element::Block(Box::new(block))) + } else if token.is_word("IF") { + if self.dialect != Dialect::SqlServer && self.has_procedural_keyword_ahead(&["THEN"]) { + self.parse_if()?.map(|block| Element::If(Box::new(block))) + } else if self.allows_statements_without_semicolons() { + self + .parse_conditional(stops)? + .map(|conditional| Element::Conditional(Box::new(conditional))) + } else { + None + } + } else if token.is_word("WHILE") { + if self.has_procedural_keyword_ahead(&["LOOP", "DO"]) { + self.parse_loop()?.map(|block| Element::Loop(Box::new(block))) + } else if self.allows_statements_without_semicolons() { + self + .parse_conditional(stops)? + .map(|conditional| Element::Conditional(Box::new(conditional))) + } else { + None + } + } else if (token.is_word("FOR") || token.is_word("FOREACH")) + && self.dialect != Dialect::SqlServer + && self.has_procedural_keyword_ahead(&["LOOP", "DO"]) + { + self.parse_loop()?.map(|block| Element::Loop(Box::new(block))) + } else if (token.is_word("LOOP") || token.is_word("REPEAT")) && self.dialect != Dialect::SqlServer { + self.parse_loop()?.map(|block| Element::Loop(Box::new(block))) + } else if token.is_word("CASE") && self.dialect != Dialect::SqlServer { + self + .parse_case_statement()? + .map(|statement| Element::CaseStatement(Box::new(statement))) + } else { + None + }; + if result.is_none() { + self.pos = start; + self.failed_constructs.insert(start); + } + Ok(result) + } + + /// Whether the `BEGIN` at `index` starts a block rather than a transaction. + fn is_block_begin(&self, index: usize) -> bool { + match self.tokens.get(index + 1) { + None => false, + Some(next) => { + // a transaction's `BEGIN` may also be ended by a command to the client (ex. psql's `\g`) + !matches!( + next.kind, + TokenKind::Semicolon | TokenKind::Delimiter | TokenKind::BatchSeparator | TokenKind::Directive + ) && !is_terminator(next) + && ![ + "TRAN", + "TRANSACTION", + "WORK", + "ISOLATION", + "READ", + "DEFERRED", + "IMMEDIATE", + "EXCLUSIVE", + "DISTRIBUTED", + "DIALOG", + "CONVERSATION", + "NAME", + ] + .iter() + .any(|word| next.is_word(word)) + } + } + } + + fn parse_block(&mut self) -> Result>, SyntaxError> { + self.enter()?; + let mut begin = vec![self.advance()]; + while let Some(token) = self.peek().filter(|token| { + !token.starts_line() + && (token.is_word("TRY") || token.is_word("CATCH") || token.is_word("ATOMIC") || token.is_word("NOT")) + }) { + begin.push(token); + self.pos += 1; + } + let has_exception_section = !matches!(self.dialect, Dialect::SqlServer | Dialect::MySql | Dialect::Sqlite); + let body = self.parse_list(Stops { + end: true, + exception: has_exception_section, + ..Default::default() + })?; + let exception = if has_exception_section && self.peek().is_some_and(|token| token.is_word("EXCEPTION")) { + Some(self.parse_exception_section()?) + } else { + None + }; + let Some(end) = self.peek().filter(|token| token.is_word("END")) else { + self.leave(); + return Ok(None); + }; + self.pos += 1; + let mut end = vec![end]; + if let Some(suffix) = self.peek().filter(|token| { + token.newlines_before == 0 + && (token.is_word("TRY") + || token.is_word("CATCH") + || (matches!(token.kind, TokenKind::Word | TokenKind::QuotedIdent) + && !keywords::contains(keywords::RESERVED, token.text))) + }) { + end.push(suffix); + self.pos += 1; + } + self.leave(); + Ok(Some(Block { + begin, + body, + exception, + end, + })) + } + + fn parse_exception_section(&mut self) -> Result, SyntaxError> { + let keyword = self.advance(); + let leading_comments = self.take_comments(); + let mut handlers = Vec::new(); + while self.peek().is_some_and(|token| token.is_word("WHEN")) { + let Some(handler) = self.parse_handler(Stops { + when: true, + end: true, + ..Default::default() + })? + else { + break; + }; + handlers.push(handler); + } + Ok(ExceptionSection { + keyword, + leading_comments, + handlers, + }) + } + + /// `WHEN condition THEN statements`, starting at the `WHEN`. + fn parse_handler(&mut self, body_stops: Stops) -> Result>, SyntaxError> { + let start = self.pos; + let when = self.advance(); + let Some(condition) = self.parse_elements_until(&["THEN"])? else { + self.pos = start; + return Ok(None); + }; + let then = self.advance(); + let body = self.parse_list(body_stops)?; + Ok(Some(Handler { + when, + condition, + then, + body, + })) + } + + fn parse_if(&mut self) -> Result>, SyntaxError> { + self.enter()?; + let body_stops = Stops { + end: true, + else_: true, + elsif: true, + ..Default::default() + }; + let mut branches = Vec::new(); + loop { + let keyword = self.advance(); + let Some(condition) = self.parse_elements_until(&["THEN"])? else { + self.leave(); + return Ok(None); + }; + let then = self.advance(); + let body = self.parse_list(body_stops)?; + branches.push(IfBranch { + keyword, + condition, + then, + body, + }); + if !self + .peek() + .is_some_and(|token| token.is_word("ELSIF") || token.is_word("ELSEIF")) + { + break; + } + } + let else_branch = if self.peek().is_some_and(|token| token.is_word("ELSE")) { + let keyword = self.advance(); + let body = self.parse_list(Stops { + end: true, + ..Default::default() + })?; + Some(ElseBranch { keyword, body }) + } else { + None + }; + let end = self.parse_end(&["IF"]); + self.leave(); + Ok(end.map(|end| IfBlock { + branches, + else_branch, + end, + })) + } + + /// T-SQL's `IF condition statement [ELSE statement]` or `WHILE condition statement`. + fn parse_conditional(&mut self, stops: Stops) -> Result>, SyntaxError> { + self.enter()?; + let keyword = self.advance(); + let is_if = keyword.is_word("IF"); + let mut condition = Vec::new(); + loop { + let Some(token) = self.peek() else { + self.leave(); + return Ok(None); + }; + if matches!( + token.kind, + TokenKind::Semicolon | TokenKind::Delimiter | TokenKind::BatchSeparator | TokenKind::Directive + ) || is_closing(token) + { + self.leave(); + return Ok(None); + } + if !condition.is_empty() + && token.kind == TokenKind::Word + && keywords::contains(keywords::TSQL_STATEMENT_KEYWORDS, token.text) + { + let is_call = self + .tokens + .get(self.pos + 1) + .is_some_and(|next| next.kind == TokenKind::OpenParen && !next.space_before); + if !is_call { + break; + } + } + condition.push(self.parse_element(&StatementState::default())?); + } + let body = self.parse_statement(Stops { else_: is_if, ..stops })?; + let mut else_comments = Vec::new(); + let mut else_branch = None; + if is_if { + let comments_start = self.pos; + let comments = self.take_comments(); + if self.peek().is_some_and(|token| token.is_word("ELSE")) { + let else_keyword = self.advance(); + else_comments = comments; + let statement = self.parse_statement(Stops { else_: true, ..stops })?; + else_branch = Some((else_keyword, Box::new(statement))); + } else { + self.pos = comments_start; + } + } + self.leave(); + Ok(Some(Conditional { + keyword, + condition, + body: Box::new(body), + else_comments, + else_branch, + })) + } + + fn parse_loop(&mut self) -> Result>, SyntaxError> { + self.enter()?; + let first = self.peek().unwrap(); + let is_repeat = first.is_word("REPEAT"); + let header = if first.is_word("LOOP") || is_repeat { + vec![Element::Token(self.advance())] + } else { + let Some(mut header) = self.parse_elements_until(&["LOOP", "DO"])? else { + self.leave(); + return Ok(None); + }; + header.push(Element::Token(self.advance())); + header + }; + let body = self.parse_list(Stops { + end: true, + until: is_repeat, + ..Default::default() + })?; + let until = if is_repeat { + if !self.peek().is_some_and(|token| token.is_word("UNTIL")) { + self.leave(); + return Ok(None); + } + let keyword = self.advance(); + let Some(condition) = self.parse_elements_until(&["END"])? else { + self.leave(); + return Ok(None); + }; + Some((keyword, condition)) + } else { + None + }; + let end = self.parse_end(&["LOOP", "WHILE", "FOR", "REPEAT", "FOREACH"]); + self.leave(); + Ok(end.map(|end| Loop { + header, + body, + until, + end, + })) + } + + fn parse_case_statement(&mut self) -> Result>, SyntaxError> { + self.enter()?; + let case_keyword = self.advance(); + let Some(operand) = self.parse_elements_until(&["WHEN"])? else { + self.leave(); + return Ok(None); + }; + let mut branches = Vec::new(); + while self.peek().is_some_and(|token| token.is_word("WHEN")) { + let Some(branch) = self.parse_handler(Stops { + when: true, + else_: true, + end: true, + ..Default::default() + })? + else { + self.leave(); + return Ok(None); + }; + branches.push(branch); + } + let else_branch = if self.peek().is_some_and(|token| token.is_word("ELSE")) { + let keyword = self.advance(); + let body = self.parse_list(Stops { + end: true, + ..Default::default() + })?; + Some(ElseBranch { keyword, body }) + } else { + None + }; + let end = self.parse_end(&["CASE"]); + self.leave(); + Ok(end.filter(|_| !branches.is_empty()).map(|end| CaseStatement { + case_keyword, + operand, + branches, + else_branch, + end, + })) + } + + /// `END` followed by one of `kinds` and optionally a label, or `None` when that isn't what's + /// next. + fn parse_end(&mut self, kinds: &[&str]) -> Option>> { + let end = self.peek().filter(|token| token.is_word("END"))?; + let kind = self + .tokens + .get(self.pos + 1) + .filter(|token| kinds.iter().any(|kind| token.is_word(kind)))?; + self.pos += 2; + let mut tokens = vec![end, kind]; + if let Some(label) = self.peek().filter(|token| { + token.newlines_before == 0 + && matches!(token.kind, TokenKind::Word | TokenKind::QuotedIdent) + && !keywords::contains(keywords::RESERVED, token.text) + }) { + tokens.push(label); + self.pos += 1; + } + Some(tokens) + } + + /// A label before a block or loop: `<>`, or `name:` in MySQL and BigQuery. + fn parse_label(&mut self) -> Option> { + let first = self.peek()?; + if first.is_operator("<<") { + let name = self.tokens.get(self.pos + 1)?; + let close = self.tokens.get(self.pos + 2)?; + if matches!(name.kind, TokenKind::Word | TokenKind::QuotedIdent) && close.is_operator(">>") { + self.pos += 3; + return Some(Label { + tokens: vec![first, name, close], + own_line: true, + }); + } + return None; + } + if matches!(first.kind, TokenKind::Word | TokenKind::QuotedIdent) + && !keywords::contains(keywords::RESERVED, first.text) + { + let colon = self.tokens.get(self.pos + 1)?; + if colon.is_operator(":") && !colon.space_before { + let after = self.tokens.get(self.pos + 2); + let is_label = after.is_none_or(|after| after.starts_line() || self.is_labelable(after)); + if is_label { + self.pos += 2; + return Some(Label { + tokens: vec![first, colon], + own_line: false, + }); + } + } + } + None + } + + fn is_labelable(&self, token: &Token) -> bool { + ["BEGIN", "LOOP", "WHILE", "REPEAT", "FOR"] + .iter() + .any(|word| token.is_word(word)) + } + + // ---- declarations ---- + + /// A `DECLARE` that starts a section of declarations before a block's `BEGIN`, as opposed to a + /// statement declaring a variable (ex. T-SQL's `DECLARE @x INT`). + fn parse_declare_section_at_start(&mut self) -> Result>, SyntaxError> { + let Some(token) = self.peek().filter(|token| token.is_word("DECLARE")) else { + return Ok(None); + }; + let tentative = match self.dialect { + Dialect::SqlServer | Dialect::MySql | Dialect::BigQuery | Dialect::Sqlite => return Ok(None), + Dialect::Oracle | Dialect::Snowflake => false, + Dialect::PostgreSql => false, + Dialect::Generic => true, + }; + if !self.starts_declarations(self.pos + 1) { + return Ok(None); + } + let start = self.pos; + self.pos += 1; + let Some(declarations) = self.parse_declarations()? else { + self.pos = start; + return Ok(None); + }; + let followed_by_block = self.peek().is_some_and(|token| token.is_word("BEGIN")); + if !followed_by_block || (tentative && declarations.items.is_empty()) { + if !followed_by_block { + self.failed_declarations.insert(start + 1); + } + self.pos = start; + return Ok(None); + } + Ok(Some(DeclareSection { + keyword: Some(token), + declarations, + })) + } + + /// Whether the tokens at `index` are a declaration in a declaration section rather than the + /// rest of a `DECLARE` statement. + fn starts_declarations(&self, index: usize) -> bool { + let Some(first) = self.tokens.get(index) else { + return false; + }; + if first.is_word("BEGIN") { + // an empty section + return true; + } + if !matches!(first.kind, TokenKind::Word | TokenKind::QuotedIdent) || first.kind == TokenKind::Variable { + return false; + } + // a PostgreSQL cursor (`DECLARE name [BINARY] [INSENSITIVE] [NO] [SCROLL] CURSOR`) + let is_cursor = self.tokens[index + 1..] + .iter() + .take_while(|token| token.kind == TokenKind::Word) + .take(5) + .take_while(|token| { + ["BINARY", "INSENSITIVE", "ASENSITIVE", "NO", "SCROLL", "CURSOR"] + .iter() + .any(|w| token.is_word(w)) + }) + .any(|token| token.is_word("CURSOR")); + if is_cursor && self.dialect != Dialect::Oracle { + return false; + } + // a MySQL handler or condition (`DECLARE CONTINUE HANDLER ..`) + if ["CONTINUE", "EXIT", "UNDO"].iter().any(|w| first.is_word(w)) { + return false; + } + true + } + + /// The declarations of a declaration section, up to the `BEGIN` or `END` after them, or `None` + /// when something other than a declaration comes first, in which case it wasn't a declaration + /// section after all. + /// + /// Each declaration is checked as it's reached rather than all of them once the `BEGIN` is, which + /// keeps a mistaken guess cheap: otherwise the rest of the file would be read as declarations to + /// find out, again for every later statement that looked like the start of a section. + fn parse_declarations(&mut self) -> Result>, SyntaxError> { + let start = self.pos; + if self.failed_declarations.contains(&start) { + return Ok(None); + } + self.enter()?; + let outer = std::mem::replace(&mut self.not_declarations, false); + let list = self.parse_list(Stops { + begin: true, + end: true, + declarations: true, + ..Default::default() + })?; + let failed = std::mem::replace(&mut self.not_declarations, outer); + if failed { + self.failed_declarations.insert(start); + } + self.leave(); + Ok(if failed { None } else { Some(list) }) + } + + /// The declarations after the `IS` or `AS` of an Oracle routine, if that's what follows. + fn parse_routine_declarations(&mut self) -> Result>, SyntaxError> { + let Some(next) = self.peek() else { + return Ok(None); + }; + let is_keyword = self.tokens[self.pos - 1].is_word("IS"); + let follows = match self.dialect { + Dialect::Oracle => { + !next.is_word("BEGIN") + && !next.is_word("LANGUAGE") + && !next.is_word("EXTERNAL") + && matches!(next.kind, TokenKind::Word | TokenKind::QuotedIdent) + } + Dialect::Generic => { + !next.is_word("BEGIN") + && (if is_keyword { + matches!(next.kind, TokenKind::Word | TokenKind::QuotedIdent) + && !next.is_word("LANGUAGE") + && !next.is_word("EXTERNAL") + } else { + self.looks_like_declaration(self.pos) + }) + } + _ => false, + }; + if !follows { + return Ok(None); + } + let start = self.pos; + let declarations = self.parse_declarations()?; + // the declarations are followed by the routine's block, or the end of a package's + // specification, and without either they weren't declarations after all + let Some(declarations) = declarations.filter(|_| { + self + .peek() + .is_some_and(|token| token.is_word("BEGIN") || token.is_word("END")) + }) else { + self.failed_declarations.insert(start); + self.pos = start; + return Ok(None); + }; + Ok(Some(DeclareSection { + keyword: None, + declarations, + })) + } + + /// Whether the tokens at `index` look like an Oracle declaration (`name type`). + fn looks_like_declaration(&self, index: usize) -> bool { + let Some(first) = self.tokens.get(index) else { + return false; + }; + if ["CURSOR", "TYPE", "SUBTYPE", "PRAGMA", "PROCEDURE", "FUNCTION"] + .iter() + .any(|word| first.is_word(word)) + { + return true; + } + let is_name = (first.kind == TokenKind::Word && !keywords::contains(keywords::RESERVED, first.text)) + || first.kind == TokenKind::QuotedIdent; + is_name + && self + .tokens + .get(index + 1) + .is_some_and(|token| token.kind == TokenKind::Word) + } + + // ---- elements ---- + + fn parse_element(&mut self, state: &StatementState) -> Result, SyntaxError> { + let token = self.peek().unwrap(); + match token.kind { + TokenKind::OpenParen | TokenKind::OpenBracket | TokenKind::OpenBrace => { + Ok(Element::Group(Box::new(self.parse_group()?))) + } + TokenKind::Word if token.is_word("CASE") && !self.failed_constructs.contains(&self.pos) => { + let start = self.pos; + match self.parse_case()? { + Some(case) => Ok(Element::Case(Box::new(case))), + None => { + self.failed_constructs.insert(start); + self.pos = start + 1; + Ok(Element::Token(token)) + } + } + } + TokenKind::Word + if token.is_word("BEGIN") + && state.allows_block() + && self.is_block_begin(self.pos) + && !self.failed_constructs.contains(&self.pos) => + { + let start = self.pos; + match self.parse_block()? { + Some(block) => Ok(Element::Block(Box::new(block))), + None => { + self.failed_constructs.insert(start); + self.pos = start + 1; + Ok(Element::Token(token)) + } + } + } + TokenKind::Word + if token.is_word("DECLARE") + && state.routine + && matches!( + self.dialect, + Dialect::Oracle | Dialect::Generic | Dialect::PostgreSql | Dialect::Snowflake + ) + && self.starts_declarations(self.pos + 1) + && !self.failed_declarations.contains(&(self.pos + 1)) => + { + let start = self.pos; + self.pos += 1; + let declarations = self.parse_declarations()?; + let Some(declarations) = declarations.filter(|_| self.peek().is_some_and(|token| token.is_word("BEGIN"))) + else { + self.failed_declarations.insert(start + 1); + self.pos = start + 1; + return Ok(Element::Token(token)); + }; + Ok(Element::DeclareSection(Box::new(DeclareSection { + keyword: Some(token), + declarations, + }))) + } + _ => { + self.pos += 1; + Ok(Element::Token(token)) + } + } + } + + fn parse_group(&mut self) -> Result, SyntaxError> { + self.enter()?; + let open = self.advance(); + let close_kind = match open.kind { + TokenKind::OpenParen => TokenKind::CloseParen, + TokenKind::OpenBracket => TokenKind::CloseBracket, + _ => TokenKind::CloseBrace, + }; + let mut elements = Vec::new(); + loop { + let Some(token) = self.peek() else { + return Err(self.error(open, &format!("unclosed '{}'", open.text))); + }; + if token.kind == close_kind { + break; + } + if is_closing(token) { + return Err(self.error( + token, + &format!("expected '{}' to close the '{}'", closing_text(close_kind), open.text), + )); + } + elements.push(self.parse_element(&StatementState::default())?); + } + let close = self.advance(); + self.leave(); + Ok(Group { open, elements, close }) + } + + fn parse_case(&mut self) -> Result>, SyntaxError> { + self.enter()?; + let case_keyword = self.advance(); + let Some(mut operand) = self.parse_elements_until(&["WHEN"])? else { + self.leave(); + return Ok(None); + }; + let mut branches = Vec::new(); + let mut pending_comments = take_trailing_own_line_comments(&mut operand); + while self.peek().is_some_and(|token| token.is_word("WHEN")) { + let when = self.advance(); + let Some(condition) = self.parse_elements_until(&["THEN"])? else { + self.leave(); + return Ok(None); + }; + let then = self.advance(); + let Some(mut result) = self.parse_elements_until(&["WHEN", "ELSE", "END"])? else { + self.leave(); + return Ok(None); + }; + let next_comments = take_trailing_own_line_comments(&mut result); + branches.push(CaseBranch { + leading_comments: std::mem::replace(&mut pending_comments, next_comments), + when, + condition, + then, + result, + }); + } + if branches.is_empty() { + self.leave(); + return Ok(None); + } + let else_branch = if self.peek().is_some_and(|token| token.is_word("ELSE")) { + let keyword = self.advance(); + let Some(mut result) = self.parse_elements_until(&["END"])? else { + self.leave(); + return Ok(None); + }; + let next_comments = take_trailing_own_line_comments(&mut result); + Some(CaseElse { + leading_comments: std::mem::replace(&mut pending_comments, next_comments), + keyword, + result, + }) + } else { + None + }; + let Some(end) = self.peek().filter(|token| token.is_word("END")) else { + self.leave(); + return Ok(None); + }; + self.pos += 1; + self.leave(); + Ok(Some(Case { + case_keyword, + operand, + branches, + else_branch, + end_comments: pending_comments, + end, + })) + } + + /// The elements up to one of the words `stops`, which is left as the next token, or `None` when + /// the statement or the enclosing brackets end first. + fn parse_elements_until(&mut self, stops: &[&str]) -> Result>>, SyntaxError> { + let mut elements = Vec::new(); + loop { + let Some(token) = self.peek() else { + return Ok(None); + }; + if stops.iter().any(|stop| token.is_word(stop)) { + return Ok(Some(elements)); + } + if matches!( + token.kind, + TokenKind::Semicolon | TokenKind::Delimiter | TokenKind::BatchSeparator | TokenKind::Directive + ) || is_closing(token) + { + return Ok(None); + } + elements.push(self.parse_element(&StatementState::default())?); + } + } + + // ---- helpers ---- + + /// Whether a procedural keyword (ex. the `THEN` of an `IF`) comes before the end of the + /// statement, outside of any brackets or `CASE` expression. + fn has_procedural_keyword_ahead(&self, words: &[&str]) -> bool { + let mut depth = 0usize; + let mut case_depth = 0usize; + for token in &self.tokens[self.pos + 1..] { + match token.kind { + TokenKind::OpenParen | TokenKind::OpenBracket | TokenKind::OpenBrace => depth += 1, + TokenKind::CloseParen | TokenKind::CloseBracket | TokenKind::CloseBrace => { + if depth == 0 { + return false; + } + depth -= 1; + } + TokenKind::Semicolon | TokenKind::Delimiter | TokenKind::BatchSeparator if depth == 0 => return false, + TokenKind::Word if depth == 0 => { + if token.is_word("CASE") { + case_depth += 1; + } else if token.is_word("END") && case_depth > 0 { + case_depth -= 1; + } else if case_depth == 0 { + if words.iter().any(|word| token.is_word(word)) { + return true; + } + if token.is_word("BEGIN") { + return false; + } + } + } + _ => {} + } + } + false + } + + fn take_comments(&mut self) -> Vec> { + let mut comments = Vec::new(); + while let Some(token) = self.peek().filter(|token| token.is_comment()) { + comments.push(token); + self.pos += 1; + } + comments + } + + fn peek(&self) -> Option> { + self.tokens.get(self.pos) + } + + fn peek_past_comments(&self) -> Option> { + self.tokens[self.pos..].iter().find(|token| !token.is_comment()) + } + + fn advance(&mut self) -> Tok<'a> { + let token = &self.tokens[self.pos]; + self.pos += 1; + token + } + + fn enter(&mut self) -> Result<(), SyntaxError> { + self.depth += 1; + if self.depth > MAX_DEPTH { + let token = self.peek().unwrap_or(&self.tokens[self.tokens.len() - 1]); + return Err(self.error(token, "nested too deeply")); + } + Ok(()) + } + + fn leave(&mut self) { + self.depth -= 1; + } + + fn error(&self, token: &Token, message: &str) -> SyntaxError { + SyntaxError { + message: message.to_string(), + span: token.start..token.end(), + } + } +} + +/// What the statement being parsed looks like so far, which decides whether a word that could +/// start a statement continues this one instead. +#[derive(Default)] +struct StatementState<'a> { + /// The statement's first word. + head: Option<&'a str>, + word_count: usize, + /// Whether the statement creates a procedure, function, trigger or package. + routine: bool, + /// Whether the statement declares a MySQL handler, whose body is a statement. + handler: bool, + /// Whether a `SELECT` or `VALUES` has been seen. + query_started: bool, + /// The first of `SELECT`, `INSERT`, `UPDATE`, `DELETE` or `MERGE`. + main_dml: Option<&'a str>, + prev_word: Option<&'a str>, + prev_is_comma_or_operator: bool, + /// Whether the statement is within a declaration section. + declarations: bool, + /// Whether the last element was a declaration section, which a block follows. + after_declarations: bool, + /// Whether the kind of object a `CREATE` or `ALTER` is of has been seen. + object_kind_seen: bool, + /// Whether the `SET` of an `UPDATE` has been seen. + update_set_seen: bool, +} + +impl<'a> StatementState<'a> { + fn update(&mut self, element: &Element<'a>) { + self.after_declarations = matches!(element, Element::DeclareSection(_)); + let Some(token) = element.as_token() else { + self.prev_word = None; + self.prev_is_comma_or_operator = false; + if self.head.is_none() { + self.head = Some(""); + } + return; + }; + if token.is_comment() { + return; + } + self.prev_is_comma_or_operator = matches!(token.kind, TokenKind::Comma | TokenKind::Operator | TokenKind::Dot); + if token.kind != TokenKind::Word { + self.prev_word = None; + if self.head.is_none() { + self.head = Some(""); + } + return; + } + let word = token.text; + if self.head.is_none() { + self.head = Some(word); + } + self.word_count += 1; + // a routine in an Oracle package or type body (ex. `MEMBER FUNCTION f RETURN NUMBER IS ..`) + if self.declarations && self.word_count <= 3 && (token.is_word("PROCEDURE") || token.is_word("FUNCTION")) { + self.routine = true; + } + let head_is_create = self + .head + .is_some_and(|head| head.eq_ignore_ascii_case("CREATE") || head.eq_ignore_ascii_case("ALTER")); + // the kind is the first word naming one (ex. not the `event` of `CREATE VIEW v AS SELECT event`) + if head_is_create + && self.word_count <= 8 + && !self.object_kind_seen + && let Some(is_routine) = keywords::object_kind_is_routine(word) + { + self.object_kind_seen = true; + self.routine |= is_routine; + } + if token.is_word("HANDLER") { + self.handler = true; + } + if token.is_word("SELECT") || token.is_word("VALUES") { + self.query_started = true; + } + if token.is_word("SET") && self.main_dml.is_some_and(|dml| dml.eq_ignore_ascii_case("UPDATE")) { + self.update_set_seen = true; + } + if self.main_dml.is_none() + && ["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE"] + .iter() + .any(|dml| token.is_word(dml)) + { + self.main_dml = Some(word); + } + self.prev_word = Some(word); + } + + /// Whether a `BEGIN` in the middle of the statement starts a block. + fn allows_block(&self) -> bool { + self.routine + || self.handler + || self.after_declarations + || self.prev_word.is_some_and(|word| { + ["AS", "IS", "DO", "THEN", "ELSE"] + .iter() + .any(|candidate| word.eq_ignore_ascii_case(candidate)) + }) + } +} + +/// Whether the token ends a statement: a `;`, a terminator set by a MySQL `DELIMITER` command, or +/// psql's `\;`, which ends a statement without sending it until the next `;`. +fn is_terminator(token: &Token) -> bool { + matches!(token.kind, TokenKind::Semicolon | TokenKind::Delimiter) + || (token.kind == TokenKind::Other && token.text == "\\;") +} + +fn is_closing(token: &Token) -> bool { + matches!( + token.kind, + TokenKind::CloseParen | TokenKind::CloseBracket | TokenKind::CloseBrace + ) +} + +fn closing_text(kind: TokenKind) -> &'static str { + match kind { + TokenKind::CloseParen => ")", + TokenKind::CloseBracket => "]", + _ => "}", + } +} + +/// Removes the comments at the end of `elements` that are on lines of their own, which introduce +/// whatever comes next rather than ending what's before them. +fn take_trailing_own_line_comments<'a>(elements: &mut Vec>) -> Vec> { + let mut split = elements.len(); + while split > 0 { + match elements[split - 1].as_token() { + Some(token) if token.is_comment() && token.newlines_before > 0 => split -= 1, + _ => break, + } + } + elements + .split_off(split) + .into_iter() + .filter_map(|element| element.as_token()) + .collect() +} diff --git a/src/wasm_plugin.rs b/src/wasm_plugin.rs index 59dffa9..ac3a903 100644 --- a/src/wasm_plugin.rs +++ b/src/wasm_plugin.rs @@ -1,6 +1,8 @@ -use super::configuration::{resolve_config, Configuration}; +use super::configuration::Configuration; +use super::configuration::resolve_config; -use dprint_core::configuration::{ConfigKeyMap, GlobalConfiguration}; +use dprint_core::configuration::ConfigKeyMap; +use dprint_core::configuration::GlobalConfiguration; use dprint_core::generate_plugin_code; use dprint_core::plugins::CheckConfigUpdatesMessage; use dprint_core::plugins::ConfigChange; @@ -28,6 +30,7 @@ impl SyncPluginHandler for SqlPluginHandler { file_matching: FileMatchingInfo { file_extensions: vec!["sql".to_string()], file_names: vec![], + additive: false, }, } } @@ -60,13 +63,13 @@ impl SyncPluginHandler for SqlPluginHandler { request: SyncFormatRequest, _format_with_host: impl FnMut(SyncHostFormatRequest) -> FormatResult, ) -> FormatResult { - if request.range.is_some() { - return Ok(None); // not implemented + let file_text = String::from_utf8(request.file_bytes)?; + let result = match request.range { + Some(range) => super::format_text_range(request.file_path, &file_text, range, request.config), + None => super::format_text(request.file_path, &file_text, request.config), } - - let text = String::from_utf8_lossy(&request.file_bytes); - let maybe_text = super::format_text(request.file_path, &text, request.config); - Ok(maybe_text.map(|t| t.into_bytes())) + .map_err(FormatError::new)?; + Ok(result.map(|file_text| file_text.into_bytes())) } } diff --git a/tests/spec_test.rs b/tests/spec_test.rs new file mode 100644 index 0000000..987f1bd --- /dev/null +++ b/tests/spec_test.rs @@ -0,0 +1,66 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use dprint_core::configuration::*; +use dprint_development::*; +use dprint_plugin_sql::configuration::resolve_config; +use dprint_plugin_sql::*; + +fn main() { + let global_config = GlobalConfiguration::default(); + + run_specs( + &PathBuf::from("./tests/specs"), + &ParseSpecOptions { + default_file_name: "file.sql", + }, + &RunSpecsOptions { + fix_failures: false, + format_twice: true, + }, + { + let global_config = global_config.clone(); + Arc::new(move |file_path, file_text, range, spec_config| { + let spec_config: ConfigKeyMap = serde_json::from_value(spec_config.clone().into()).unwrap(); + let config_result = resolve_config(spec_config, &global_config); + ensure_no_diagnostics(&config_result.diagnostics); + + Ok(match range { + Some(range) => format_text_range(file_path, file_text, range, &config_result.config)?, + None => format_text(file_path, file_text, &config_result.config)?, + }) + }) + }, + Arc::new(move |file_path, file_text, spec_config| trace(file_path, file_text, spec_config, &global_config)), + ) +} + +#[cfg(feature = "tracing")] +fn trace( + file_path: &Path, + file_text: &str, + spec_config: &SpecConfigMap, + global_config: &GlobalConfiguration, +) -> String { + let spec_config: ConfigKeyMap = serde_json::from_value(spec_config.clone().into()).unwrap(); + let config_result = resolve_config(spec_config, global_config); + ensure_no_diagnostics(&config_result.diagnostics); + serde_json::to_string(&trace_file(file_path, file_text, &config_result.config)).unwrap() +} + +#[cfg(not(feature = "tracing"))] +fn trace( + _file_path: &Path, + _file_text: &str, + _spec_config: &SpecConfigMap, + _global_config: &GlobalConfiguration, +) -> String { + panic!( + " +==== +Please run with `cargo test --features tracing` to get trace output +==== +" + ) +} diff --git a/tests/specs/Basic/Basic_All.txt b/tests/specs/Basic/Basic_All.txt deleted file mode 100644 index 931e751..0000000 --- a/tests/specs/Basic/Basic_All.txt +++ /dev/null @@ -1,84 +0,0 @@ -== should format basic == -SELECT * FROM dbo.Test WHERE test = 'asdf' -GO - - - -update dbo.Test Set test=1 -GO - -[expect] -SELECT - * -FROM - dbo.Test -WHERE - test = 'asdf' -GO -update - dbo.Test -Set - test = 1 -GO - -== should format subquery == -SELECT * FROM (SELECT id, name FROM users WHERE active = 1) AS t WHERE t.id > 10 - -[expect] -SELECT - * -FROM - ( - SELECT - id, - name - FROM - users - WHERE - active = 1 - ) AS t -WHERE - t.id > 10 - -== should format insert == -INSERT INTO users (id, name, email) VALUES (1, 'test', 'test@example.com') - -[expect] -INSERT INTO - users (id, name, email) -VALUES - (1, 'test', 'test@example.com') - -== should format join == -SELECT u.name, o.total FROM users u INNER JOIN orders o ON u.id = o.user_id WHERE o.total > 100 - -[expect] -SELECT - u.name, - o.total -FROM - users u - INNER JOIN orders o ON u.id = o.user_id -WHERE - o.total > 100 - -== should format create table == -CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE) - -[expect] -CREATE TABLE users ( - id INT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - email VARCHAR(255) UNIQUE -) - -== should format multiple queries == -SELECT 1; SELECT 2; SELECT 3 - -[expect] -SELECT - 1; -SELECT - 2; -SELECT - 3 diff --git a/tests/specs/BigQuery/BigQuery_All.txt b/tests/specs/BigQuery/BigQuery_All.txt new file mode 100644 index 0000000..f06b583 --- /dev/null +++ b/tests/specs/BigQuery/BigQuery_All.txt @@ -0,0 +1,149 @@ +~~ dialect: bigquery ~~ +== should format backtick paths == +SELECT * FROM `project.dataset.table` WHERE _PARTITIONTIME > TIMESTAMP '2020-01-01'; + +[expect] +SELECT * +FROM `project.dataset.table` +WHERE _PARTITIONTIME > TIMESTAMP '2020-01-01'; + +== should format except and replace == +SELECT * EXCEPT (a, b) REPLACE (c * 2 AS c) FROM t; + +[expect] +SELECT * EXCEPT (a, b) REPLACE (c * 2 AS c) +FROM t; + +== should format structs and arrays == +SELECT STRUCT(1 AS a, 'x' AS b), [1, 2, 3], ARRAY[1, 2], t.s.field FROM t, UNNEST(t.items) AS item; + +[expect] +SELECT STRUCT(1 AS a, 'x' AS b), [1, 2, 3], ARRAY[1, 2], t.s.field +FROM t, UNNEST(t.items) AS item; + +== should format raw and triple quoted strings == +SELECT r'\d+', b'bytes', '''multi +line''', """also +multi"""; + +[expect] +SELECT r'\d+', b'bytes', '''multi +line''', """also +multi"""; + +== should format hash comments == +# a comment +SELECT 1; # after + +[expect] +# a comment +SELECT 1; # after + +== should format scripting == +DECLARE x INT64 DEFAULT 0; +DECLARE y STRING; +SET x = x + 1; +IF x > 0 THEN SELECT 'positive'; ELSEIF x < 0 THEN SELECT 'negative'; ELSE SELECT 'zero'; END IF; +LOOP SET x = x + 1; IF x > 10 THEN BREAK; END IF; END LOOP; +WHILE x > 0 DO SET x = x - 1; END WHILE; +FOR row IN (SELECT 1 AS n) DO SELECT row.n; END FOR; + +[expect] +DECLARE x INT64 DEFAULT 0; +DECLARE y STRING; +SET x = x + 1; +IF x > 0 THEN + SELECT 'positive'; +ELSEIF x < 0 THEN + SELECT 'negative'; +ELSE + SELECT 'zero'; +END IF; +LOOP + SET x = x + 1; + IF x > 10 THEN + BREAK; + END IF; +END LOOP; +WHILE x > 0 DO + SET x = x - 1; +END WHILE; +FOR row IN (SELECT 1 AS n) DO + SELECT row.n; +END FOR; + +== should format blocks with exception handlers == +BEGIN +SELECT 1 / 0; +EXCEPTION WHEN ERROR THEN +SELECT @@error.message; +END; + +[expect] +BEGIN + SELECT 1 / 0; +EXCEPTION + WHEN ERROR THEN + SELECT @@error.message; +END; + +== should format a procedure == +CREATE OR REPLACE PROCEDURE dataset.p(x INT64, OUT y INT64) +BEGIN +SET y = x * 2; +END; + +[expect] +CREATE OR REPLACE PROCEDURE dataset.p(x INT64, OUT y INT64) +BEGIN + SET y = x * 2; +END; + +== should format call == +CALL dataset.p(1, y); + +[expect] +CALL dataset.p(1, y); + +== should format qualify and window == +SELECT a, ROW_NUMBER() OVER (PARTITION BY b ORDER BY c) AS rn FROM t WHERE TRUE QUALIFY rn = 1; + +[expect] +SELECT a, ROW_NUMBER() OVER (PARTITION BY b ORDER BY c) AS rn +FROM t +WHERE TRUE +QUALIFY rn = 1; + +== should format query parameters == +SELECT * FROM t WHERE a = @a AND b = @@project_id; + +[expect] +SELECT * +FROM t +WHERE a = @a AND b = @@project_id; + +== should end a variable's name at a comment == +SELECT * FROM t WHERE x = @v# note + AND y = 1; + +[expect] +SELECT * +FROM t +WHERE x = @v # note + AND y = 1; + +== should keep the dashes of a project's name == +SELECT * FROM my-project.dataset.tbl t JOIN dataset.my-table u USING (id) WHERE a-b > 1; + +[expect] +SELECT * +FROM my-project.dataset.tbl t + JOIN dataset.my-table u USING (id) +WHERE a - b > 1; + +== should end a raw string and a quoted name after an escaped quote == +SELECT r'\'' AS a, `a\`b` FROM t; + +[expect] +SELECT r'\'' AS a, `a\`b` +FROM t; diff --git a/tests/specs/Comments/BlankLines.txt b/tests/specs/Comments/BlankLines.txt new file mode 100644 index 0000000..20374fc --- /dev/null +++ b/tests/specs/Comments/BlankLines.txt @@ -0,0 +1,71 @@ +== should keep a blank line between statements == +SELECT 1; + +SELECT 2; +SELECT 3; + +[expect] +SELECT 1; + +SELECT 2; +SELECT 3; + +== should collapse several blank lines into one == +SELECT 1; + + + +SELECT 2; + +[expect] +SELECT 1; + +SELECT 2; + +== should remove blank lines at the start and end of the file == + + +SELECT 1; + +[expect] +SELECT 1; + +== should remove blank lines within a statement == +SELECT a, + +b + +FROM t + +WHERE c = 1; + +[expect] +SELECT a, b +FROM t +WHERE c = 1; + +== should keep blank lines between statements in a block == +BEGIN +SELECT 1; + +SELECT 2; +END; + +[expect] +BEGIN + SELECT 1; + + SELECT 2; +END; + +== should keep a blank line before a comment == +SELECT 1; + +-- about the next one +SELECT 2; + +[expect] +SELECT 1; + +-- about the next one +SELECT 2; diff --git a/tests/specs/Comments/Comments_All.txt b/tests/specs/Comments/Comments_All.txt new file mode 100644 index 0000000..3db1d9e --- /dev/null +++ b/tests/specs/Comments/Comments_All.txt @@ -0,0 +1,233 @@ +== should keep comments between statements == +-- first +SELECT 1; +/* second */ +SELECT 2; + +[expect] +-- first +SELECT 1; +/* second */ +SELECT 2; + +== should keep a comment after a statement on its line == +SELECT 1; -- one +SELECT 2; /* two */ +SELECT 3; /* three */ -- and more + +[expect] +SELECT 1; -- one +SELECT 2; /* two */ +SELECT 3; /* three */ -- and more + +== should keep comments after list items == +SELECT a, -- the a +b -- the b +FROM t; + +[expect] +SELECT + a, -- the a + b -- the b +FROM t; + +== should move a comment before a comma after it == +SELECT a -- the a +, b +FROM t; + +[expect] +SELECT + a, -- the a + b +FROM t; + +== should keep comments on lines of their own within a list == +SELECT a, +-- before b +b +FROM t; + +[expect] +SELECT + a, + -- before b + b +FROM t; + +== should keep a comment after a keyword on its line == +SELECT -- the columns +a, b +FROM t; + +[expect] +SELECT -- the columns + a, + b +FROM t; + +== should keep a comment before a clause == +SELECT a FROM t +-- only some +WHERE b = 1; + +[expect] +SELECT a +FROM t +-- only some +WHERE b = 1; + +== should keep comments within conditions == +SELECT * FROM t WHERE a = 1 -- first +-- second +AND b = 2; + +[expect] +SELECT * +FROM t +WHERE a = 1 -- first + -- second + AND b = 2; + +== should keep comments within brackets == +SELECT f(a, -- first argument +b) FROM t; + +[expect] +SELECT f( + a, -- first argument + b +) +FROM t; + +== should keep block comments in place == +SELECT /* all */ * FROM t /* the table */ WHERE /* a */ a = 1; + +[expect] +SELECT /* all */ * +FROM t /* the table */ +WHERE /* a */ a = 1; + +== should keep a block comment before a comma == +SELECT f(a /* x */, b, /* y */ c) FROM t; + +[expect] +SELECT f(a /* x */, b, /* y */ c) +FROM t; + +== should keep multi-line block comments as they are == +/* + * a header + */ +SELECT 1; + +[expect] +/* + * a header + */ +SELECT 1; + +== should keep comments within a case == +SELECT CASE -- by kind +WHEN a THEN 1 -- one +-- before two +WHEN b THEN 2 +ELSE 3 -- otherwise +END FROM t; + +[expect] +SELECT + CASE -- by kind + WHEN a THEN 1 -- one + -- before two + WHEN b THEN 2 + ELSE 3 -- otherwise + END +FROM t; + +== should keep comments before joins == +SELECT * FROM a -- a +-- join b +JOIN b ON a.id = b.id -- on +-- join c +LEFT JOIN c ON c.id = b.id; + +[expect] +SELECT * +FROM a -- a + -- join b + JOIN b ON a.id = b.id -- on + -- join c + LEFT JOIN c ON c.id = b.id; + +== should keep a comment in brackets that hold nothing else == +SELECT f( +-- nothing +); + +[expect] +SELECT f( + -- nothing +); + +== should keep a comment before a semicolon == +SELECT 1 -- the end +; + +[expect] +SELECT 1 -- the end +; + +== should keep comments in a create table == +CREATE TABLE t ( +-- the id +id INT, -- primary +name TEXT -- the name +); + +[expect] +CREATE TABLE t ( + -- the id + id INT, -- primary + name TEXT -- the name +); + +== should keep a comment at the end of the file == +SELECT 1; +-- the end + +[expect] +SELECT 1; +-- the end + +== should keep a file of comments == +-- one +-- two + +/* three */ + +[expect] +-- one +-- two + +/* three */ + +== should indent a clause beneath a comment on its own line after the keyword == +select a from t where +/* c */ +x = 1 and y = 2 +group by a having +-- why +count(*) > 1; + +[expect] +select a +from t +where + /* c */ + x = 1 + and y = 2 +group by a +having + -- why + count(*) > 1; diff --git a/tests/specs/Comments/Ignore.txt b/tests/specs/Comments/Ignore.txt new file mode 100644 index 0000000..82477a4 --- /dev/null +++ b/tests/specs/Comments/Ignore.txt @@ -0,0 +1,45 @@ +== should leave the statement after an ignore comment alone == +SELECT 1; +-- dprint-ignore +SELECT a,b + FROM t; +SELECT 2; + +[expect] +SELECT 1; +-- dprint-ignore +SELECT a,b + FROM t; +SELECT 2; + +== should leave a procedure after an ignore comment alone == +/* dprint-ignore */ +CREATE PROCEDURE p() BEGIN + SELECT 1; +END; +SELECT 3; + +[expect] +/* dprint-ignore */ +CREATE PROCEDURE p() BEGIN + SELECT 1; +END; +SELECT 3; + +== should only ignore the next statement == +-- dprint-ignore +SELECT 1; +SELECT 2; + +[expect] +-- dprint-ignore +SELECT 1; +SELECT 2; + +== should not read a longer word as an ignore comment == +-- dprint-ignored +SELECT 1; + +[expect] +-- dprint-ignored +SELECT 1; diff --git a/tests/specs/Config/Casing.txt b/tests/specs/Config/Casing.txt deleted file mode 100644 index 16f8803..0000000 --- a/tests/specs/Config/Casing.txt +++ /dev/null @@ -1,10 +0,0 @@ -== should preserve casing by default == -SELECT * FROM users WHERE id = 1 - -[expect] -SELECT - * -FROM - users -WHERE - id = 1 diff --git a/tests/specs/Config/CasingLower.txt b/tests/specs/Config/CasingLower.txt deleted file mode 100644 index 87cf894..0000000 --- a/tests/specs/Config/CasingLower.txt +++ /dev/null @@ -1,11 +0,0 @@ -~~ casing: lower ~~ -== should lowercase with casing lower == -SELECT * FROM users WHERE id = 1 - -[expect] -select - * -from - users -where - id = 1 diff --git a/tests/specs/Config/CasingUpper.txt b/tests/specs/Config/CasingUpper.txt deleted file mode 100644 index a2fbac3..0000000 --- a/tests/specs/Config/CasingUpper.txt +++ /dev/null @@ -1,11 +0,0 @@ -~~ casing: upper ~~ -== should uppercase with casing upper == -select * from users where id = 1 - -[expect] -SELECT - * -FROM - users -WHERE - id = 1 diff --git a/tests/specs/Config/Casing_Lower.txt b/tests/specs/Config/Casing_Lower.txt new file mode 100644 index 0000000..d742efa --- /dev/null +++ b/tests/specs/Config/Casing_Lower.txt @@ -0,0 +1,26 @@ +~~ casing: lower ~~ +== should lowercase keywords == +SELECT A, B FROM T WHERE C = 1 AND D IS NOT NULL ORDER BY A DESC; + +[expect] +select A, B +from T +where C = 1 and D is not null +order by A desc; + +== should not lowercase names == +SELECT Name, FIRST FROM Users; + +[expect] +select Name, FIRST +from Users; + +== should lowercase the keywords of procedural code == +BEGIN IF X THEN RETURN 1; END IF; END; + +[expect] +begin + if X then + return 1; + end if; +end; diff --git a/tests/specs/Config/Casing_Preserve.txt b/tests/specs/Config/Casing_Preserve.txt new file mode 100644 index 0000000..0af22d7 --- /dev/null +++ b/tests/specs/Config/Casing_Preserve.txt @@ -0,0 +1,8 @@ +== should preserve casing by default == +SELECT a FROM t where b = 1 Order By a; + +[expect] +SELECT a +FROM t +where b = 1 +Order By a; diff --git a/tests/specs/Config/Casing_Upper.txt b/tests/specs/Config/Casing_Upper.txt new file mode 100644 index 0000000..08ccb20 --- /dev/null +++ b/tests/specs/Config/Casing_Upper.txt @@ -0,0 +1,77 @@ +~~ casing: upper ~~ +== should uppercase keywords == +select a, b from t where c = 1 and d is not null order by a desc; + +[expect] +SELECT a, b +FROM t +WHERE c = 1 AND d IS NOT NULL +ORDER BY a DESC; + +== should not uppercase names == +select name, value, key, type, status, first, last from users; + +[expect] +SELECT name, value, key, type, status, first, last +FROM users; + +== should not uppercase quoted names or names in qualified names == +select "select", t.order, t.from, x.date from t; + +[expect] +SELECT "select", t.order, t.from, x.date +FROM t; + +== should uppercase keywords that are only keywords where they are == +create table t (id int primary key, b int references u (id) on delete no action); +select a from t order by a nulls first fetch next 5 rows only; + +[expect] +CREATE TABLE t (id INT PRIMARY KEY, b INT REFERENCES u (id) ON DELETE NO ACTION); +SELECT a +FROM t +ORDER BY a NULLS FIRST +FETCH NEXT 5 ROWS ONLY; + +== should uppercase the keywords of procedural code == +create procedure p() begin declare x int default 0; if x > 0 then set x = 1; end if; while x < 10 do set x = x + 1; end while; end; + +[expect] +CREATE PROCEDURE p() +BEGIN + DECLARE x INT DEFAULT 0; + IF x > 0 THEN + SET x = 1; + END IF; + WHILE x < 10 DO + SET x = x + 1; + END WHILE; +END; + +== should uppercase types after casts == +select a::integer, b::text, c::my_type from t; + +[expect] +SELECT a::INTEGER, b::TEXT, c::my_type +FROM t; + +== should not uppercase functions == +select count(*), coalesce(a, b), now() from t; + +[expect] +SELECT count(*), coalesce(a, b), now() +FROM t; + +== should keep the case of a table named like a keyword == +select * from transaction t join view v on t.id = v.id; +update transaction set a = 1; +create view v as select 1; + +[expect] +SELECT * +FROM transaction t + JOIN view v ON t.id = v.id; +UPDATE transaction +SET a = 1; +CREATE VIEW v AS +SELECT 1; diff --git a/tests/specs/Config/Indent.txt b/tests/specs/Config/Indent.txt deleted file mode 100644 index 6aebf84..0000000 --- a/tests/specs/Config/Indent.txt +++ /dev/null @@ -1,11 +0,0 @@ -~~ useTabs: true ~~ -== should use tabs == -SELECT * FROM users WHERE id = 1 - -[expect] -SELECT - * -FROM - users -WHERE - id = 1 diff --git a/tests/specs/Config/IndentWidth.txt b/tests/specs/Config/IndentWidth.txt deleted file mode 100644 index 49643a1..0000000 --- a/tests/specs/Config/IndentWidth.txt +++ /dev/null @@ -1,11 +0,0 @@ -~~ indentWidth: 4 ~~ -== should use 4 space indent == -SELECT * FROM users WHERE id = 1 - -[expect] -SELECT - * -FROM - users -WHERE - id = 1 diff --git a/tests/specs/Config/IndentWidth_4.txt b/tests/specs/Config/IndentWidth_4.txt new file mode 100644 index 0000000..f8334cc --- /dev/null +++ b/tests/specs/Config/IndentWidth_4.txt @@ -0,0 +1,18 @@ +~~ indentWidth: 4, lineWidth: 40 ~~ +== should indent by four spaces == +SELECT first_name, last_name, email FROM users u JOIN orders o ON o.user_id = u.id; + +[expect] +SELECT first_name, last_name, email +FROM users u + JOIN orders o ON o.user_id = u.id; + +== should indent blocks by four spaces == +BEGIN IF x THEN SELECT 1; END IF; END; + +[expect] +BEGIN + IF x THEN + SELECT 1; + END IF; +END; diff --git a/tests/specs/Config/JoinsAsTopLevel.txt b/tests/specs/Config/JoinsAsTopLevel.txt deleted file mode 100644 index de3695e..0000000 --- a/tests/specs/Config/JoinsAsTopLevel.txt +++ /dev/null @@ -1,14 +0,0 @@ -~~ joinsAsTopLevel: true ~~ -== should treat joins as top level == -SELECT u.name, o.total FROM users u INNER JOIN orders o ON u.id = o.user_id LEFT JOIN payments p ON o.id = p.order_id - -[expect] -SELECT - u.name, - o.total -FROM - users u -INNER JOIN - orders o ON u.id = o.user_id -LEFT JOIN - payments p ON o.id = p.order_id diff --git a/tests/specs/Config/JoinsAsTopLevel_True.txt b/tests/specs/Config/JoinsAsTopLevel_True.txt new file mode 100644 index 0000000..545a520 --- /dev/null +++ b/tests/specs/Config/JoinsAsTopLevel_True.txt @@ -0,0 +1,10 @@ +~~ joinsAsTopLevel: true ~~ +== should write joins at the indentation of from == +SELECT u.name, o.total FROM users u INNER JOIN orders o ON u.id = o.user_id LEFT JOIN payments p ON o.id = p.order_id WHERE o.total > 100; + +[expect] +SELECT u.name, o.total +FROM users u +INNER JOIN orders o ON u.id = o.user_id +LEFT JOIN payments p ON o.id = p.order_id +WHERE o.total > 100; diff --git a/tests/specs/Config/LineWidth_40.txt b/tests/specs/Config/LineWidth_40.txt new file mode 100644 index 0000000..5a69265 --- /dev/null +++ b/tests/specs/Config/LineWidth_40.txt @@ -0,0 +1,32 @@ +~~ lineWidth: 40 ~~ +== should break a row that doesn't fit the line width == +INSERT INTO `projects` (`name`, `night_of`) VALUES ('R Wireless', unixepoch('2026-06-30 17:00:00-04:00')); + +[expect] +INSERT INTO `projects` ( + `name`, + `night_of` +) +VALUES ( + 'R Wireless', + unixepoch('2026-06-30 17:00:00-04:00') +); + +== should keep rows on one line that fit the line width == +INSERT INTO t (a, b) VALUES (1, 2), (3, 4); + +[expect] +INSERT INTO t (a, b) +VALUES (1, 2), (3, 4); + +== should keep the actions of an alter in it == +ALTER TABLE users UPDATE status = 'active', last_login = now() WHERE user_id IN (1, 2, 3); + +[expect] +ALTER TABLE users + UPDATE status = 'active', + last_login = now() WHERE user_id IN ( + 1, + 2, + 3 + ); diff --git a/tests/specs/Config/LinesBetweenQueries.txt b/tests/specs/Config/LinesBetweenQueries.txt deleted file mode 100644 index 45de593..0000000 --- a/tests/specs/Config/LinesBetweenQueries.txt +++ /dev/null @@ -1,10 +0,0 @@ -~~ linesBetweenQueries: 2 ~~ -== should use 2 lines between queries == -SELECT 1; SELECT 2 - -[expect] -SELECT - 1; - -SELECT - 2 diff --git a/tests/specs/Config/LinesBetweenQueries_2.txt b/tests/specs/Config/LinesBetweenQueries_2.txt new file mode 100644 index 0000000..1daf0bf --- /dev/null +++ b/tests/specs/Config/LinesBetweenQueries_2.txt @@ -0,0 +1,47 @@ +~~ linesBetweenQueries: 2 ~~ +== should separate statements with a blank line == +SELECT 1; SELECT 2; +SELECT 3; + +[expect] +SELECT 1; + +SELECT 2; + +SELECT 3; + +== should keep a comment against the statement it introduces == +SELECT 1; +-- the second +SELECT 2; + +[expect] +SELECT 1; + +-- the second +SELECT 2; + +== should not separate statements within a block == +BEGIN +SELECT 1; +SELECT 2; +END; +SELECT 3; + +[expect] +BEGIN + SELECT 1; + SELECT 2; +END; + +SELECT 3; + +== should not separate a statement from the batch separator after it == +SELECT 1 +GO +SELECT 2 + +[expect] +SELECT 1 +GO +SELECT 2 diff --git a/tests/specs/Config/UppercaseLegacy.txt b/tests/specs/Config/UppercaseLegacy.txt deleted file mode 100644 index 4bf0946..0000000 --- a/tests/specs/Config/UppercaseLegacy.txt +++ /dev/null @@ -1,11 +0,0 @@ -~~ uppercase: true ~~ -== should support legacy uppercase boolean == -select * from users where id = 1 - -[expect] -SELECT - * -FROM - users -WHERE - id = 1 diff --git a/tests/specs/Config/Uppercase_Legacy.txt b/tests/specs/Config/Uppercase_Legacy.txt new file mode 100644 index 0000000..eae8fc5 --- /dev/null +++ b/tests/specs/Config/Uppercase_Legacy.txt @@ -0,0 +1,8 @@ +~~ uppercase: true ~~ +== should support the legacy uppercase option == +select * from users where id = 1 + +[expect] +SELECT * +FROM users +WHERE id = 1 diff --git a/tests/specs/Config/UseTabs_True.txt b/tests/specs/Config/UseTabs_True.txt new file mode 100644 index 0000000..3c6cf32 --- /dev/null +++ b/tests/specs/Config/UseTabs_True.txt @@ -0,0 +1,18 @@ +~~ useTabs: true, lineWidth: 40 ~~ +== should indent with tabs == +SELECT first_name, last_name, email FROM users u JOIN orders o ON o.user_id = u.id; + +[expect] +SELECT first_name, last_name, email +FROM users u + JOIN orders o ON o.user_id = u.id; + +== should indent blocks with tabs == +BEGIN IF x THEN SELECT 1; END IF; END; + +[expect] +BEGIN + IF x THEN + SELECT 1; + END IF; +END; diff --git a/tests/specs/Ddl/CreateTable.txt b/tests/specs/Ddl/CreateTable.txt new file mode 100644 index 0000000..06b4e7a --- /dev/null +++ b/tests/specs/Ddl/CreateTable.txt @@ -0,0 +1,101 @@ +== should format a create table == +CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE) + +[expect] +CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE) + +== should keep a create table the author wrote over several lines == +CREATE TABLE users ( +id INT PRIMARY KEY, +name VARCHAR(255) NOT NULL +); + +[expect] +CREATE TABLE users ( + id INT PRIMARY KEY, + name VARCHAR(255) NOT NULL +); + +== should break a create table that doesn't fit == +CREATE TABLE orders (id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers (id), total NUMERIC(10, 2) DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now()); + +[expect] +CREATE TABLE orders ( + id BIGSERIAL PRIMARY KEY, + customer_id BIGINT NOT NULL REFERENCES customers (id), + total NUMERIC(10, 2) DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() +); + +== should format table constraints == +CREATE TABLE t ( + a INT, + b INT, + CONSTRAINT pk PRIMARY KEY (a, b), + CONSTRAINT fk FOREIGN KEY (b) REFERENCES u (id) ON DELETE CASCADE ON UPDATE NO ACTION, + CHECK (a > 0), + UNIQUE (b) +); + +[expect] +CREATE TABLE t ( + a INT, + b INT, + CONSTRAINT pk PRIMARY KEY (a, b), + CONSTRAINT fk FOREIGN KEY (b) REFERENCES u (id) ON DELETE CASCADE ON UPDATE NO ACTION, + CHECK (a > 0), + UNIQUE (b) +); + +== should keep blank lines between columns == +CREATE TABLE t ( + a INT, + + b INT +); + +[expect] +CREATE TABLE t ( + a INT, + + b INT +); + +== should format if not exists and temporary == +CREATE TEMPORARY TABLE IF NOT EXISTS t (a INT); + +[expect] +CREATE TEMPORARY TABLE IF NOT EXISTS t (a INT); + +== should format create table as == +CREATE TABLE t AS SELECT a, b FROM u WHERE c = 1; + +[expect] +CREATE TABLE t AS +SELECT a, b +FROM u +WHERE c = 1; + +== should format table options == +CREATE TABLE t (id INT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +[expect] +CREATE TABLE t (id INT) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + +== should format partitioning == +CREATE TABLE t (id INT, d DATE) PARTITION BY RANGE (d); + +[expect] +CREATE TABLE t (id INT, d DATE) PARTITION BY RANGE (d); + +== should format generated columns == +CREATE TABLE t (a INT, b INT GENERATED ALWAYS AS (a * 2) STORED, c INT GENERATED BY DEFAULT AS IDENTITY); + +[expect] +CREATE TABLE t (a INT, b INT GENERATED ALWAYS AS (a * 2) STORED, c INT GENERATED BY DEFAULT AS IDENTITY); + +== should format like == +CREATE TABLE t (LIKE u INCLUDING ALL); + +[expect] +CREATE TABLE t (LIKE u INCLUDING ALL); diff --git a/tests/specs/Ddl/Ddl_All.txt b/tests/specs/Ddl/Ddl_All.txt new file mode 100644 index 0000000..50636db --- /dev/null +++ b/tests/specs/Ddl/Ddl_All.txt @@ -0,0 +1,141 @@ +== should format a create view == +CREATE VIEW v AS SELECT a, b FROM t WHERE c = 1; + +[expect] +CREATE VIEW v AS +SELECT a, b +FROM t +WHERE c = 1; + +== should format create or replace view == +CREATE OR REPLACE VIEW v (x, y) AS SELECT a, b FROM t; + +[expect] +CREATE OR REPLACE VIEW v (x, y) AS +SELECT a, b +FROM t; + +== should format a materialized view == +CREATE MATERIALIZED VIEW mv AS SELECT a, count(*) FROM t GROUP BY a WITH DATA; + +[expect] +CREATE MATERIALIZED VIEW mv AS +SELECT a, count(*) +FROM t +GROUP BY a WITH DATA; + +== should format a view of a cte == +CREATE VIEW v AS WITH a AS (SELECT 1 AS x) SELECT x FROM a; + +[expect] +CREATE VIEW v AS +WITH a AS (SELECT 1 AS x) +SELECT x +FROM a; + +== should format a create index == +CREATE UNIQUE INDEX IF NOT EXISTS idx ON t (a, b DESC) WHERE c IS NULL; + +[expect] +CREATE UNIQUE INDEX IF NOT EXISTS idx ON t (a, b DESC) WHERE c IS NULL; + +== should format an alter table with one action == +ALTER TABLE t ADD COLUMN c INT NOT NULL DEFAULT 0; + +[expect] +ALTER TABLE t ADD COLUMN c INT NOT NULL DEFAULT 0; + +== should format an alter table with several actions == +ALTER TABLE t ADD COLUMN c INT, DROP COLUMN d; + +[expect] +ALTER TABLE t ADD COLUMN c INT, DROP COLUMN d; + +== should break an alter table that doesn't fit == +ALTER TABLE customers ADD COLUMN middle_name VARCHAR(100), ADD COLUMN suffix VARCHAR(20), DROP COLUMN fax_number, DROP COLUMN pager; + +[expect] +ALTER TABLE customers + ADD COLUMN middle_name VARCHAR(100), + ADD COLUMN suffix VARCHAR(20), + DROP COLUMN fax_number, + DROP COLUMN pager; + +== should format alter table actions on lines of their own == +ALTER TABLE t +ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES u (id) + +[expect] +ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES u (id) + +== should format alter column == +ALTER TABLE t ALTER COLUMN a SET DEFAULT 1, ALTER COLUMN b TYPE TEXT; + +[expect] +ALTER TABLE t ALTER COLUMN a SET DEFAULT 1, ALTER COLUMN b TYPE TEXT; + +== should format rename == +ALTER TABLE t RENAME COLUMN a TO b; + +[expect] +ALTER TABLE t RENAME COLUMN a TO b; + +== should format drops == +DROP TABLE IF EXISTS a, b CASCADE; +DROP VIEW v; +DROP INDEX idx; + +[expect] +DROP TABLE IF EXISTS a, b CASCADE; +DROP VIEW v; +DROP INDEX idx; + +== should format truncate == +TRUNCATE TABLE t RESTART IDENTITY; + +[expect] +TRUNCATE TABLE t RESTART IDENTITY; + +== should format grants == +GRANT SELECT, INSERT ON t TO u WITH GRANT OPTION; +REVOKE ALL ON t FROM u; + +[expect] +GRANT SELECT, INSERT ON t TO u WITH GRANT OPTION; +REVOKE ALL ON t FROM u; + +== should format a create schema == +CREATE SCHEMA IF NOT EXISTS app AUTHORIZATION admin; + +[expect] +CREATE SCHEMA IF NOT EXISTS app AUTHORIZATION admin; + +== should format a create sequence == +CREATE SEQUENCE s START WITH 1 INCREMENT BY 1; + +[expect] +CREATE SEQUENCE s START WITH 1 INCREMENT BY 1; + +== should format comments on objects == +COMMENT ON TABLE t IS 'the table'; + +[expect] +COMMENT ON TABLE t IS 'the table'; + +== should format a create type == +CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy'); + +[expect] +CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy'); + +== should format a create domain == +CREATE DOMAIN positive AS INT CHECK (VALUE > 0); + +[expect] +CREATE DOMAIN positive AS INT CHECK (VALUE > 0); + +== should format a create extension == +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +[expect] +CREATE EXTENSION IF NOT EXISTS pgcrypto; diff --git a/tests/specs/Ddl/Transactions.txt b/tests/specs/Ddl/Transactions.txt new file mode 100644 index 0000000..2e5ef1a --- /dev/null +++ b/tests/specs/Ddl/Transactions.txt @@ -0,0 +1,71 @@ +== should format a transaction == +BEGIN; +UPDATE accounts SET balance = balance - 100 WHERE id = 1; +UPDATE accounts SET balance = balance + 100 WHERE id = 2; +COMMIT; + +[expect] +BEGIN; +UPDATE accounts +SET balance = balance - 100 +WHERE id = 1; +UPDATE accounts +SET balance = balance + 100 +WHERE id = 2; +COMMIT; + +== should format begin transaction == +BEGIN TRANSACTION; +ROLLBACK TRANSACTION; + +[expect] +BEGIN TRANSACTION; +ROLLBACK TRANSACTION; + +== should format start transaction == +START TRANSACTION ISOLATION LEVEL SERIALIZABLE; +COMMIT; + +[expect] +START TRANSACTION ISOLATION LEVEL SERIALIZABLE; +COMMIT; + +== should format savepoints == +SAVEPOINT a; +ROLLBACK TO SAVEPOINT a; +RELEASE SAVEPOINT a; + +[expect] +SAVEPOINT a; +ROLLBACK TO SAVEPOINT a; +RELEASE SAVEPOINT a; + +== should format begin work == +BEGIN WORK; +COMMIT WORK; + +[expect] +BEGIN WORK; +COMMIT WORK; + +== should format sqlite's begin == +BEGIN IMMEDIATE TRANSACTION; +END TRANSACTION; + +[expect] +BEGIN IMMEDIATE TRANSACTION; +END TRANSACTION; + +== should format set statements == +SET search_path TO app, public; +SET TIME ZONE 'UTC'; + +[expect] +SET search_path TO app, public; +SET TIME ZONE 'UTC'; + +== should format use == +USE my_database; + +[expect] +USE my_database; diff --git a/tests/specs/Dml/Delete.txt b/tests/specs/Dml/Delete.txt new file mode 100644 index 0000000..edf4b82 --- /dev/null +++ b/tests/specs/Dml/Delete.txt @@ -0,0 +1,59 @@ +== should format a delete == +DELETE FROM t WHERE id = 1; + +[expect] +DELETE FROM t +WHERE id = 1; + +== should format a delete without a where == +DELETE FROM t; + +[expect] +DELETE FROM t; + +== should format using == +DELETE FROM t USING u WHERE u.id = t.id; + +[expect] +DELETE FROM t +USING u +WHERE u.id = t.id; + +== should format returning == +DELETE FROM t WHERE a = 1 RETURNING *; + +[expect] +DELETE FROM t +WHERE a = 1 +RETURNING *; + +== should format a t-sql delete from a join == +DELETE t FROM t INNER JOIN u ON u.id = t.id WHERE u.b = 1; + +[expect] +DELETE t +FROM t + INNER JOIN u ON u.id = t.id +WHERE u.b = 1; + +== should format top == +DELETE TOP (10) FROM t WHERE a = 1; + +[expect] +DELETE TOP (10) FROM t +WHERE a = 1; + +== should format order by and limit == +DELETE FROM t ORDER BY id LIMIT 10; + +[expect] +DELETE FROM t +ORDER BY id +LIMIT 10; + +== should format a subquery == +DELETE FROM t WHERE id IN (SELECT id FROM u WHERE x = 1); + +[expect] +DELETE FROM t +WHERE id IN (SELECT id FROM u WHERE x = 1); diff --git a/tests/specs/Dml/Insert.txt b/tests/specs/Dml/Insert.txt new file mode 100644 index 0000000..577dde1 --- /dev/null +++ b/tests/specs/Dml/Insert.txt @@ -0,0 +1,129 @@ +== should format an insert == +INSERT INTO users (id, name, email) VALUES (1, 'test', 'test@example.com') + +[expect] +INSERT INTO users (id, name, email) +VALUES (1, 'test', 'test@example.com') + +== should format several rows == +INSERT INTO t (a, b) VALUES (1, 2), (3, 4), (5, 6); + +[expect] +INSERT INTO t (a, b) +VALUES (1, 2), (3, 4), (5, 6); + +== should format an insert without columns == +INSERT INTO t VALUES (1, 2); + +[expect] +INSERT INTO t +VALUES (1, 2); + +== should format an insert of a query == +INSERT INTO t (a, b) SELECT a, b FROM u WHERE c = 1; + +[expect] +INSERT INTO t (a, b) +SELECT a, b +FROM u +WHERE c = 1; + +== should format default values == +INSERT INTO t DEFAULT VALUES; + +[expect] +INSERT INTO t DEFAULT VALUES; + +== should format on conflict do nothing == +INSERT INTO t (a) VALUES (1) ON CONFLICT DO NOTHING; + +[expect] +INSERT INTO t (a) +VALUES (1) +ON CONFLICT DO NOTHING; + +== should format on conflict do update == +INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT (a) DO UPDATE SET b = EXCLUDED.b, c = t.c + 1 WHERE t.b IS DISTINCT FROM EXCLUDED.b; + +[expect] +INSERT INTO t (a, b) +VALUES (1, 2) +ON CONFLICT (a) DO UPDATE +SET b = EXCLUDED.b, c = t.c + 1 +WHERE t.b IS DISTINCT FROM EXCLUDED.b; + +== should format on duplicate key update == +INSERT INTO t (a, b) VALUES (1, 2) ON DUPLICATE KEY UPDATE b = VALUES(b), c = c + 1; + +[expect] +INSERT INTO t (a, b) +VALUES (1, 2) +ON DUPLICATE KEY UPDATE b = VALUES(b), c = c + 1; + +== should format returning == +INSERT INTO t (a) VALUES (1) RETURNING id, a; + +[expect] +INSERT INTO t (a) +VALUES (1) +RETURNING id, a; + +== should format an insert written over several lines == +INSERT INTO t ( + a, + b +) +VALUES (1, 2); + +[expect] +INSERT INTO t ( + a, + b +) +VALUES (1, 2); + +== should format insert or replace == +INSERT OR REPLACE INTO t (a) VALUES (1); + +[expect] +INSERT OR REPLACE INTO t (a) +VALUES (1); + +== should format replace into == +REPLACE INTO t (a) VALUES (1); + +[expect] +REPLACE INTO t (a) +VALUES (1); + +== should format insert set == +INSERT INTO t SET a = 1, b = 2; + +[expect] +INSERT INTO t +SET a = 1, b = 2; + +== should format output == +INSERT INTO t (a) OUTPUT inserted.id VALUES (1); + +[expect] +INSERT INTO t (a) +OUTPUT inserted.id +VALUES (1); + +== should format a long column list == +INSERT INTO customer_addresses (customer_id, street_address, city, region, postal_code, country_code) VALUES (1, '1 Main St', 'Springfield', 'IL', '62701', 'US'); + +[expect] +INSERT INTO customer_addresses (customer_id, street_address, city, region, postal_code, country_code) +VALUES (1, '1 Main St', 'Springfield', 'IL', '62701', 'US'); + +== should keep a row with a function call on one line when it fits (issue #12) == +INSERT INTO + `projects` (`name`, `night_of`) +VALUES + ('R Wireless', unixepoch('2026-06-30 17:00:00-04:00')); + +[expect] +INSERT INTO `projects` (`name`, `night_of`) +VALUES ('R Wireless', unixepoch('2026-06-30 17:00:00-04:00')); diff --git a/tests/specs/Dml/Merge.txt b/tests/specs/Dml/Merge.txt new file mode 100644 index 0000000..35dd3f0 --- /dev/null +++ b/tests/specs/Dml/Merge.txt @@ -0,0 +1,68 @@ +== should format a merge == +MERGE INTO target t USING source s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name); + +[expect] +MERGE INTO target t +USING source s +ON t.id = s.id +WHEN MATCHED THEN + UPDATE SET t.name = s.name +WHEN NOT MATCHED THEN + INSERT (id, name) VALUES (s.id, s.name); + +== should format conditions of when == +MERGE INTO target AS t USING source AS s ON t.id = s.id WHEN MATCHED AND s.deleted = 1 THEN DELETE WHEN MATCHED THEN UPDATE SET t.name = s.name WHEN NOT MATCHED BY TARGET THEN INSERT (id) VALUES (s.id) WHEN NOT MATCHED BY SOURCE THEN DELETE; + +[expect] +MERGE INTO target AS t +USING source AS s +ON t.id = s.id +WHEN MATCHED AND s.deleted = 1 THEN + DELETE +WHEN MATCHED THEN + UPDATE SET t.name = s.name +WHEN NOT MATCHED BY TARGET THEN + INSERT (id) VALUES (s.id) +WHEN NOT MATCHED BY SOURCE THEN + DELETE; + +== should format a merge using a subquery == +MERGE INTO t USING (SELECT id, name FROM u WHERE active = 1) AS s ON t.id = s.id WHEN MATCHED THEN UPDATE SET name = s.name; + +[expect] +MERGE INTO t +USING (SELECT id, name FROM u WHERE active = 1) AS s +ON t.id = s.id +WHEN MATCHED THEN + UPDATE SET name = s.name; + +== should format do nothing == +MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DO NOTHING; + +[expect] +MERGE INTO t +USING s +ON t.id = s.id +WHEN MATCHED THEN + DO NOTHING; + +== should format output == +MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DELETE OUTPUT $action, deleted.id; + +[expect] +MERGE INTO t +USING s +ON t.id = s.id +WHEN MATCHED THEN + DELETE +OUTPUT $action, deleted.id; + +== should format a long set list == +MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.first_name = s.first_name, t.last_name = s.last_name, t.email = s.email, t.updated = s.updated; + +[expect] +MERGE INTO t +USING s +ON t.id = s.id +WHEN MATCHED THEN + UPDATE SET t.first_name = s.first_name, t.last_name = s.last_name, t.email = s.email, t.updated = s.updated; diff --git a/tests/specs/Dml/Update.txt b/tests/specs/Dml/Update.txt new file mode 100644 index 0000000..5a61534 --- /dev/null +++ b/tests/specs/Dml/Update.txt @@ -0,0 +1,82 @@ +== should format an update == +update dbo.Test Set test=1 + +[expect] +update dbo.Test +Set test = 1 + +== should format an update with a where == +UPDATE t SET a = 1, b = 2 WHERE id = 3; + +[expect] +UPDATE t +SET a = 1, b = 2 +WHERE id = 3; + +== should format an update from another table == +UPDATE t SET a = u.a FROM u WHERE u.id = t.id; + +[expect] +UPDATE t +SET a = u.a +FROM u +WHERE u.id = t.id; + +== should format an update with a join == +UPDATE t INNER JOIN u ON u.id = t.id SET t.a = u.a WHERE u.b = 1; + +[expect] +UPDATE t INNER JOIN u ON u.id = t.id +SET t.a = u.a +WHERE u.b = 1; + +== should format a t-sql update from a join == +UPDATE t SET a = u.a FROM t INNER JOIN u ON u.id = t.id WHERE u.b = 1; + +[expect] +UPDATE t +SET a = u.a +FROM t + INNER JOIN u ON u.id = t.id +WHERE u.b = 1; + +== should format returning == +UPDATE t SET a = a + 1 WHERE id = 1 RETURNING a; + +[expect] +UPDATE t +SET a = a + 1 +WHERE id = 1 +RETURNING a; + +== should format setting several columns at once == +UPDATE t SET (a, b) = (SELECT a, b FROM u WHERE u.id = t.id); + +[expect] +UPDATE t +SET (a, b) = (SELECT a, b FROM u WHERE u.id = t.id); + +== should format order by and limit == +UPDATE t SET a = 1 ORDER BY id LIMIT 10; + +[expect] +UPDATE t +SET a = 1 +ORDER BY id +LIMIT 10; + +== should format where current of == +UPDATE t SET a = 1 WHERE CURRENT OF c; + +[expect] +UPDATE t +SET a = 1 +WHERE CURRENT OF c; + +== should format a long set list == +UPDATE customers SET first_name = 'First', last_name = 'Last', email_address = 'first@example.com', updated_at = now() WHERE id = 1; + +[expect] +UPDATE customers +SET first_name = 'First', last_name = 'Last', email_address = 'first@example.com', updated_at = now() +WHERE id = 1; diff --git a/tests/specs/Generic/Generic_All.txt b/tests/specs/Generic/Generic_All.txt new file mode 100644 index 0000000..75db763 --- /dev/null +++ b/tests/specs/Generic/Generic_All.txt @@ -0,0 +1,134 @@ +== should tell bracketed identifiers from arrays == +SELECT [Name], arr[1], ARRAY[1, 2] FROM [dbo].[Users]; + +[expect] +SELECT [Name], arr[1], ARRAY[1, 2] +FROM [dbo].[Users]; + +== should tell hash comments from temporary tables == +# a comment +SELECT * INTO #temp FROM t; # another comment +SELECT * FROM +#temp; + +[expect] +# a comment +SELECT * +INTO #temp +FROM t; # another comment +SELECT * +FROM #temp; + +== should read go as a batch separator == +SELECT 1 +GO +SELECT 2 + +[expect] +SELECT 1 +GO +SELECT 2 + +== should read delimiter commands == +DELIMITER // +CREATE PROCEDURE p() BEGIN SELECT 1; END // +DELIMITER ; + +[expect] +DELIMITER // +CREATE PROCEDURE p() +BEGIN + SELECT 1; +END // +DELIMITER ; + +== should read the oracle slash == +BEGIN NULL; END; +/ + +[expect] +BEGIN + NULL; +END; +/ + +== should read dollar quoted strings == +SELECT $$text$$; + +[expect] +SELECT $$text$$; + +== should format t-sql without semicolons == +DECLARE @a INT +SET @a = 1 +SELECT @a + +[expect] +DECLARE @a INT +SET @a = 1 +SELECT @a + +== should format a mysql procedure == +CREATE PROCEDURE p(IN a INT) BEGIN DECLARE b INT DEFAULT 0; IF a > 0 THEN SET b = a; END IF; SELECT b; END; + +[expect] +CREATE PROCEDURE p(IN a INT) +BEGIN + DECLARE b INT DEFAULT 0; + IF a > 0 THEN + SET b = a; + END IF; + SELECT b; +END; + +== should format a t-sql procedure == +CREATE PROCEDURE dbo.p @a INT AS BEGIN SET NOCOUNT ON; IF @a > 0 SELECT @a; END +GO + +[expect] +CREATE PROCEDURE dbo.p @a INT +AS +BEGIN + SET NOCOUNT ON; + IF @a > 0 + SELECT @a; +END +GO + +== should format an oracle procedure == +CREATE OR REPLACE PROCEDURE p (a IN NUMBER) IS v NUMBER; BEGIN v := a; END p; +/ + +[expect] +CREATE OR REPLACE PROCEDURE p (a IN NUMBER) IS + v NUMBER; +BEGIN + v := a; +END p; +/ + +== should format a plpgsql function == +CREATE FUNCTION f() RETURNS int LANGUAGE plpgsql AS $$ BEGIN RETURN 1; END $$; + +[expect] +CREATE FUNCTION f() +RETURNS int +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN 1; +END +$$; + +== should format a plpgsql declare section == +DO $$ DECLARE a int := 1; b int := 2; BEGIN RAISE NOTICE '%', a + b; END $$; + +[expect] +DO $$ +DECLARE + a int := 1; + b int := 2; +BEGIN + RAISE NOTICE '%', a + b; +END +$$; diff --git a/tests/specs/Generic/Generic_Heuristics.txt b/tests/specs/Generic/Generic_Heuristics.txt new file mode 100644 index 0000000..df106c0 --- /dev/null +++ b/tests/specs/Generic/Generic_Heuristics.txt @@ -0,0 +1,142 @@ +== should read brackets within an array as arrays == +select percentile_disc(array[[null,1,0.5],[0.75,0.25,null]]) within group (order by x) from t; + +[expect] +select percentile_disc(array[[null, 1, 0.5], [0.75, 0.25, null]]) within group (order by x) +from t; + +== should not read an alias in a trigger as declarations == +CREATE TRIGGER t AFTER UPDATE ON p REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE f(); +CREATE TRIGGER u AFTER UPDATE ON p REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE f(); +SELECT 1; + +[expect] +CREATE TRIGGER t AFTER UPDATE ON p REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE PROCEDURE f(); +CREATE TRIGGER u AFTER UPDATE ON p REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE f(); +SELECT 1; + +== should not read the class of a function as declarations == +CREATE FUNCTION f AS x USING FILE y; +SELECT 1; + +[expect] +CREATE FUNCTION f +AS x USING FILE y; +SELECT 1; + +== should not split an alter table at an action == +ALTER TABLE t +TRUNCATE PARTITION p; + +[expect] +ALTER TABLE t TRUNCATE PARTITION p; + +== should read a hash within an operator as part of it == +CREATE OPERATOR #%# (leftarg = int8, procedure = f); + +[expect] +CREATE OPERATOR #%# (leftarg = int8, procedure = f); + +== should read a hash at the start of a line as a comment == +#=== a section === +SELECT 1; + +[expect] +#=== a section === +SELECT 1; + +== should read a hash after a statement as a comment == +USE db; #inline +SELECT 1; + +[expect] +USE db; #inline +SELECT 1; + +== should not split a grant at its privileges == +GRANT SELECT, UPDATE ON t TO u +ALTER TABLE t DROP COLUMN c + +[expect] +GRANT SELECT, UPDATE ON t TO u +ALTER TABLE t DROP COLUMN c + +== should split a statement after an alter table == +ALTER TABLE t ALTER COLUMN d INT +SELECT * FROM t + +[expect] +ALTER TABLE t ALTER COLUMN d INT +SELECT * +FROM t + +== should keep a dot apart from a number that it's apart from == +SELECT 1 . 5, t.a FROM t; + +[expect] +SELECT 1 . 5, t.a +FROM t; + +== should read a hash before a variable as a temporary table == +CREATE TABLE #@temp (id INT); +SELECT a #@ b FROM #@temp; + +[expect] +CREATE TABLE #@temp (id INT); +SELECT a #@ b +FROM #@temp; + +== should keep a second query on the line of the first == +SELECT 1 SELECT 2 +INSERT INTO t (a) VALUES (2) SELECT 3 + +[expect] +SELECT 1 SELECT 2 +INSERT INTO t (a) +VALUES (2) SELECT 3 + +== should split a second query that starts a line == +SELECT 1 +SELECT 2 + +[expect] +SELECT 1 +SELECT 2 + +== should not split at a merge join hint that starts a line == +SELECT * FROM a INNER HASH JOIN b ON a.id = b.id LEFT +MERGE JOIN c ON c.id = a.id + +[expect] +SELECT * +FROM a + INNER HASH JOIN b ON a.id = b.id + LEFT MERGE JOIN c ON c.id = a.id + +== should keep apart tokens that would read as one written together == +SELECT @ a, @ @b, a . b FROM t; + +[expect] +SELECT @ a, @ @b, a.b +FROM t; + +== should split statements without semicolons after a create table == +CREATE TABLE t (a INT) +INSERT INTO t VALUES (1) +DELETE FROM t + +[expect] +CREATE TABLE t (a INT) +INSERT INTO t +VALUES (1) +DELETE FROM t + +== should tell a routine from a statement naming a kind of routine == +create trigger tr after insert on t for each row insert into log values (1); +create view v as select event, name from t; + +[expect] +create trigger tr after insert on t for each row insert into log values (1); +create view v as +select event, name +from t; diff --git a/tests/specs/Generic/Templates.txt b/tests/specs/Generic/Templates.txt new file mode 100644 index 0000000..2328dcf --- /dev/null +++ b/tests/specs/Generic/Templates.txt @@ -0,0 +1,127 @@ +== should format template expressions == +select {{ ref('orders') }}.id, {{ dbt_utils.star(ref('customers')) }} from {{ ref('orders') }} where id = {{ var('id') }}; + +[expect] +select {{ ref('orders') }}.id, {{ dbt_utils.star(ref('customers')) }} +from {{ ref('orders') }} +where id = {{ var('id') }}; + +== should keep template tags on lines of their own == +select id from t where 1 = 1 +{% if is_incremental() %} +and updated_at > (select max(updated_at) from {{ this }}) +{% endif %} + +[expect] +select id +from t +where 1 = 1 + {% if is_incremental() %} + and updated_at > (select max(updated_at) from {{ this }}) +{% endif %} + +== should keep a template tag within a line == +select id from t where a = 1 {% if x %} and b = 2 {% endif %}; + +[expect] +select id +from t +where a = 1 {% if x %} and b = 2 {% endif %}; + +== should keep a config block before a query == +{{ config(materialized='table') }} +select 1 as id + +[expect] +{{ config(materialized='table') }} +select 1 as id + +== should keep template comments == +{# a comment #} +select 1; + +[expect] +{# a comment #} +select 1; + +== should format a loop over columns == +select +{% for column in columns %} +{{ column }}, +{% endfor %} +id +from t + +[expect] +select + {% for column in columns %} + {{ column }}, + {% endfor %} + id +from t + +== should keep a template tag spanning lines as it is == +select a +{% if x + and y %} +, b +{% endif %} +from t + +[expect] +select + a + {% if x + and y %} + , b +{% endif %} +from t + +== should not move a comma across a template tag == +select + id + {% if include_email %} + , email + {% endif %} +from users + +[expect] +select + id + {% if include_email %} + , email +{% endif %} +from users + +== should keep a comma within template tags == +select + {% for c in cols %} + {{ c }}{% if not loop.last %},{% endif %} + {% endfor %} +from t + +[expect] +select + {% for c in cols %} + {{ c }}{% if not loop.last %}, {% endif %} +{% endfor %} +from t + +== should start a clause after a template tag == +select * from t +-- c +{% if x %} where b = 1 {% endif %} + +[expect] +select * +from t +-- c +{% if x %} +where b = 1 {% endif %} + +== should format a list of template expressions as a list == +select {{ a }}, b, {{ c }} from t + +[expect] +select {{ a }}, b, {{ c }} +from t diff --git a/tests/specs/MySql/Procedures.txt b/tests/specs/MySql/Procedures.txt new file mode 100644 index 0000000..2783c5d --- /dev/null +++ b/tests/specs/MySql/Procedures.txt @@ -0,0 +1,223 @@ +~~ dialect: mysql ~~ +== should format a procedure with a delimiter == +DELIMITER // +CREATE PROCEDURE get_user(IN p_id INT, OUT p_name VARCHAR(100)) +BEGIN +SELECT name INTO p_name FROM users WHERE id = p_id; +END // +DELIMITER ; + +[expect] +DELIMITER // +CREATE PROCEDURE get_user(IN p_id INT, OUT p_name VARCHAR(100)) +BEGIN + SELECT name + INTO p_name + FROM users + WHERE id = p_id; +END // +DELIMITER ; + +== should format a procedure with a dollar delimiter == +DELIMITER $$ +CREATE PROCEDURE p() BEGIN SELECT 1; SELECT 2; END$$ +DELIMITER ; + +[expect] +DELIMITER $$ +CREATE PROCEDURE p() +BEGIN + SELECT 1; + SELECT 2; +END$$ +DELIMITER ; + +== should format declarations and handlers == +DELIMITER // +CREATE PROCEDURE p() +BEGIN +DECLARE done INT DEFAULT FALSE; +DECLARE v_id INT; +DECLARE cur CURSOR FOR SELECT id FROM t; +DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; +DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; +OPEN cur; +read_loop: LOOP +FETCH cur INTO v_id; +IF done THEN LEAVE read_loop; END IF; +END LOOP read_loop; +CLOSE cur; +END // +DELIMITER ; + +[expect] +DELIMITER // +CREATE PROCEDURE p() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE v_id INT; + DECLARE cur CURSOR FOR SELECT id FROM t; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + RESIGNAL; + END; + OPEN cur; + read_loop: LOOP + FETCH cur INTO v_id; + IF done THEN + LEAVE read_loop; + END IF; + END LOOP read_loop; + CLOSE cur; +END // +DELIMITER ; + +== should format control flow == +DELIMITER // +CREATE PROCEDURE counter(IN n INT) +BEGIN +DECLARE i INT DEFAULT 0; +IF n < 0 THEN SET i = -1; ELSEIF n = 0 THEN SET i = 0; ELSE SET i = 1; END IF; +WHILE i < n DO SET i = i + 1; END WHILE; +REPEAT SET i = i - 1; UNTIL i <= 0 END REPEAT; +outer_loop: WHILE i < 10 DO SET i = i + 1; ITERATE outer_loop; END WHILE outer_loop; +CASE n WHEN 1 THEN SELECT 'one'; WHEN 2 THEN SELECT 'two'; ELSE SELECT 'many'; END CASE; +CASE WHEN n > 10 THEN SELECT 'big'; ELSE SELECT 'small'; END CASE; +END // +DELIMITER ; + +[expect] +DELIMITER // +CREATE PROCEDURE counter(IN n INT) +BEGIN + DECLARE i INT DEFAULT 0; + IF n < 0 THEN + SET i = -1; + ELSEIF n = 0 THEN + SET i = 0; + ELSE + SET i = 1; + END IF; + WHILE i < n DO + SET i = i + 1; + END WHILE; + REPEAT + SET i = i - 1; + UNTIL i <= 0 + END REPEAT; + outer_loop: WHILE i < 10 DO + SET i = i + 1; + ITERATE outer_loop; + END WHILE outer_loop; + CASE n + WHEN 1 THEN + SELECT 'one'; + WHEN 2 THEN + SELECT 'two'; + ELSE + SELECT 'many'; + END CASE; + CASE + WHEN n > 10 THEN + SELECT 'big'; + ELSE + SELECT 'small'; + END CASE; +END // +DELIMITER ; + +== should format a function == +DELIMITER // +CREATE FUNCTION full_name(first VARCHAR(50), last VARCHAR(50)) RETURNS VARCHAR(101) DETERMINISTIC +BEGIN +RETURN CONCAT(first, ' ', last); +END // +DELIMITER ; + +[expect] +DELIMITER // +CREATE FUNCTION full_name(first VARCHAR(50), last VARCHAR(50)) +RETURNS VARCHAR(101) DETERMINISTIC +BEGIN + RETURN CONCAT(first, ' ', last); +END // +DELIMITER ; + +== should format a procedure with a single statement body == +CREATE PROCEDURE p() SELECT 1; + +[expect] +CREATE PROCEDURE p() SELECT 1; + +== should format a definer == +CREATE DEFINER = `admin`@`localhost` PROCEDURE p() SQL SECURITY INVOKER BEGIN SELECT 1; END; + +[expect] +CREATE DEFINER = `admin`@`localhost` PROCEDURE p() SQL SECURITY INVOKER +BEGIN + SELECT 1; +END; + +== should format a trigger == +DELIMITER // +CREATE TRIGGER before_insert BEFORE INSERT ON t FOR EACH ROW +BEGIN +IF NEW.a IS NULL THEN SET NEW.a = 0; END IF; +SET NEW.created_at = NOW(); +END // +DELIMITER ; + +[expect] +DELIMITER // +CREATE TRIGGER before_insert BEFORE INSERT ON t FOR EACH ROW +BEGIN + IF NEW.a IS NULL THEN + SET NEW.a = 0; + END IF; + SET NEW.created_at = NOW(); +END // +DELIMITER ; + +== should format a trigger with a single statement == +CREATE TRIGGER t_ai AFTER INSERT ON t FOR EACH ROW INSERT INTO log (id) VALUES (NEW.id); + +[expect] +CREATE TRIGGER t_ai AFTER INSERT ON t FOR EACH ROW INSERT INTO log (id) VALUES (NEW.id); + +== should format an event == +CREATE EVENT cleanup ON SCHEDULE EVERY 1 DAY DO BEGIN DELETE FROM sessions WHERE expires_at < NOW(); END; + +[expect] +CREATE EVENT cleanup ON SCHEDULE EVERY 1 DAY DO +BEGIN + DELETE FROM sessions + WHERE expires_at < NOW(); +END; + +== should format signal == +DELIMITER // +CREATE PROCEDURE p(IN x INT) +BEGIN +IF x < 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'negative'; END IF; +END // +DELIMITER ; + +[expect] +DELIMITER // +CREATE PROCEDURE p(IN x INT) +BEGIN + IF x < 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'negative'; + END IF; +END // +DELIMITER ; + +== should format call == +CALL get_user(1, @name); +SELECT @name; + +[expect] +CALL get_user(1, @name); +SELECT @name; diff --git a/tests/specs/MySql/Syntax.txt b/tests/specs/MySql/Syntax.txt new file mode 100644 index 0000000..7a302fa --- /dev/null +++ b/tests/specs/MySql/Syntax.txt @@ -0,0 +1,147 @@ +~~ dialect: mysql ~~ +== should format backticks == +SELECT `id`, `order` FROM `my db`.`my table` WHERE `select` = 1; + +[expect] +SELECT `id`, `order` +FROM `my db`.`my table` +WHERE `select` = 1; + +== should format hash comments == +# a comment +SELECT 1; # after +SELECT 2; #no space + +[expect] +# a comment +SELECT 1; # after +SELECT 2; #no space + +== should only read a double dash followed by a space as a comment == +SELECT 1--1; +SELECT 1-- 1 +; + +[expect] +SELECT 1 - -1; +SELECT 1 -- 1 +; + +== should format backslash escapes == +SELECT 'it\'s', "say \"hi\"", 'a\\'; + +[expect] +SELECT 'it\'s', "say \"hi\"", 'a\\'; + +== should format double quoted strings == +SELECT "text" AS a; + +[expect] +SELECT "text" AS a; + +== should format user variables == +SET @a := 1, @b = 2; +SELECT @a + @b, @@session.sql_mode, @@global.max_connections; + +[expect] +SET @a := 1, @b = 2; +SELECT @a + @b, @@session.sql_mode, @@global.max_connections; + +== should format limit with an offset == +SELECT * FROM t LIMIT 10, 20; + +[expect] +SELECT * +FROM t +LIMIT 10, 20; + +== should format insert ignore and replace == +INSERT IGNORE INTO t (a) VALUES (1); +REPLACE INTO t SET a = 1; + +[expect] +INSERT IGNORE INTO t (a) +VALUES (1); +REPLACE INTO t +SET a = 1; + +== should format the null-safe equality operator == +SELECT * FROM t WHERE a <=> b; + +[expect] +SELECT * +FROM t +WHERE a <=> b; + +== should format index hints == +SELECT * FROM t USE INDEX (idx_a) WHERE a = 1; +SELECT * FROM t FORCE INDEX (idx_a) IGNORE INDEX (idx_b); + +[expect] +SELECT * +FROM t USE INDEX (idx_a) +WHERE a = 1; +SELECT * +FROM t FORCE INDEX (idx_a) IGNORE INDEX (idx_b); + +== should format charset introducers == +SELECT _utf8mb4'text' COLLATE utf8mb4_bin, b'1010', x'FF'; + +[expect] +SELECT _utf8mb4'text' COLLATE utf8mb4_bin, b'1010', x'FF'; + +== should format a create table == +CREATE TABLE `t` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(255) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_name` (`name`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +[expect] +CREATE TABLE `t` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_name` (`name`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + +== should format alter table == +ALTER TABLE t ADD COLUMN c INT AFTER b, DROP INDEX idx, MODIFY COLUMN d TEXT; + +[expect] +ALTER TABLE t ADD COLUMN c INT AFTER b, DROP INDEX idx, MODIFY COLUMN d TEXT; + +== should format a multi-table delete == +DELETE t1, t2 FROM t1 INNER JOIN t2 ON t1.id = t2.id WHERE t1.a = 1; + +[expect] +DELETE t1, t2 +FROM t1 + INNER JOIN t2 ON t1.id = t2.id +WHERE t1.a = 1; + +== should format straight join and modifiers == +SELECT STRAIGHT_JOIN SQL_NO_CACHE a FROM t; + +[expect] +SELECT STRAIGHT_JOIN SQL_NO_CACHE a +FROM t; + +== should format group concat == +SELECT GROUP_CONCAT(DISTINCT a ORDER BY a SEPARATOR ', ') FROM t; + +[expect] +SELECT GROUP_CONCAT(DISTINCT a ORDER BY a SEPARATOR ', ') +FROM t; + +== should format executable comments == +/*!40101 SET NAMES utf8 */; + +[expect] +/*!40101 SET NAMES utf8 */; + +== should end a variable's name at a comment == +SELECT * FROM t WHERE x = @v# note + AND y = 1; + +[expect] +SELECT * +FROM t +WHERE x = @v # note + AND y = 1; diff --git a/tests/specs/Oracle/Packages.txt b/tests/specs/Oracle/Packages.txt new file mode 100644 index 0000000..3979f6f --- /dev/null +++ b/tests/specs/Oracle/Packages.txt @@ -0,0 +1,83 @@ +~~ dialect: oracle ~~ +== should format a package specification == +CREATE OR REPLACE PACKAGE emp_pkg AS +c_max CONSTANT NUMBER := 100; +PROCEDURE hire (p_name VARCHAR2); +FUNCTION count_all RETURN NUMBER; +END emp_pkg; +/ + +[expect] +CREATE OR REPLACE PACKAGE emp_pkg AS + c_max CONSTANT NUMBER := 100; + PROCEDURE hire (p_name VARCHAR2); + FUNCTION count_all RETURN NUMBER; +END emp_pkg; +/ + +== should format a package body == +CREATE OR REPLACE PACKAGE BODY emp_pkg AS +PROCEDURE hire (p_name VARCHAR2) IS BEGIN INSERT INTO employees (name) VALUES (p_name); END hire; +FUNCTION count_all RETURN NUMBER IS v NUMBER; BEGIN SELECT COUNT(*) INTO v FROM employees; RETURN v; END count_all; +END emp_pkg; +/ + +[expect] +CREATE OR REPLACE PACKAGE BODY emp_pkg AS + PROCEDURE hire (p_name VARCHAR2) IS + BEGIN + INSERT INTO employees (name) + VALUES (p_name); + END hire; + FUNCTION count_all RETURN NUMBER IS + v NUMBER; + BEGIN + SELECT COUNT(*) + INTO v + FROM employees; + RETURN v; + END count_all; +END emp_pkg; +/ + +== should format a package body with an initialization block == +CREATE PACKAGE BODY p IS +g_count NUMBER; +PROCEDURE reset IS BEGIN g_count := 0; END; +BEGIN +g_count := 0; +END p; +/ + +[expect] +CREATE PACKAGE BODY p IS + g_count NUMBER; + PROCEDURE reset IS + BEGIN + g_count := 0; + END; +BEGIN + g_count := 0; +END p; +/ + +== should format a type body == +CREATE TYPE BODY demo_typ IS MEMBER FUNCTION get_square RETURN NUMBER IS x NUMBER; BEGIN SELECT c.col * c.col INTO x FROM t c; RETURN x; END; STATIC PROCEDURE reset IS BEGIN NULL; END; END; +/ + +[expect] +CREATE TYPE BODY demo_typ IS + MEMBER FUNCTION get_square RETURN NUMBER IS + x NUMBER; + BEGIN + SELECT c.col * c.col + INTO x + FROM t c; + RETURN x; + END; + STATIC PROCEDURE reset IS + BEGIN + NULL; + END; +END; +/ diff --git a/tests/specs/Oracle/Procedures.txt b/tests/specs/Oracle/Procedures.txt new file mode 100644 index 0000000..e47572c --- /dev/null +++ b/tests/specs/Oracle/Procedures.txt @@ -0,0 +1,227 @@ +~~ dialect: oracle ~~ +== should format a procedure == +CREATE OR REPLACE PROCEDURE raise_salary (p_emp_id IN NUMBER, p_amount IN NUMBER) IS +v_salary NUMBER; +BEGIN +SELECT salary INTO v_salary FROM employees WHERE employee_id = p_emp_id; +UPDATE employees SET salary = v_salary + p_amount WHERE employee_id = p_emp_id; +COMMIT; +EXCEPTION WHEN NO_DATA_FOUND THEN DBMS_OUTPUT.PUT_LINE('not found'); WHEN OTHERS THEN ROLLBACK; RAISE; +END raise_salary; +/ + +[expect] +CREATE OR REPLACE PROCEDURE raise_salary (p_emp_id IN NUMBER, p_amount IN NUMBER) IS + v_salary NUMBER; +BEGIN + SELECT salary + INTO v_salary + FROM employees + WHERE employee_id = p_emp_id; + UPDATE employees + SET salary = v_salary + p_amount + WHERE employee_id = p_emp_id; + COMMIT; +EXCEPTION + WHEN NO_DATA_FOUND THEN + DBMS_OUTPUT.PUT_LINE('not found'); + WHEN OTHERS THEN + ROLLBACK; + RAISE; +END raise_salary; +/ + +== should format a procedure with as == +CREATE PROCEDURE p AS v NUMBER := 0; BEGIN v := v + 1; END; +/ + +[expect] +CREATE PROCEDURE p AS + v NUMBER := 0; +BEGIN + v := v + 1; +END; +/ + +== should format a procedure without declarations == +CREATE PROCEDURE p AS BEGIN NULL; END; +/ + +[expect] +CREATE PROCEDURE p +AS +BEGIN + NULL; +END; +/ + +== should format a function == +CREATE OR REPLACE FUNCTION get_name (p_id IN NUMBER) RETURN VARCHAR2 IS v_name VARCHAR2(100); BEGIN SELECT name INTO v_name FROM t WHERE id = p_id; RETURN v_name; END get_name; +/ + +[expect] +CREATE OR REPLACE FUNCTION get_name (p_id IN NUMBER) RETURN VARCHAR2 IS + v_name VARCHAR2(100); +BEGIN + SELECT name + INTO v_name + FROM t + WHERE id = p_id; + RETURN v_name; +END get_name; +/ + +== should format declarations == +DECLARE +c_limit CONSTANT NUMBER := 10; +v_name employees.last_name%TYPE; +v_row employees%ROWTYPE; +CURSOR c_emp IS SELECT * FROM employees WHERE department_id = 10; +TYPE t_names IS TABLE OF VARCHAR2(100) INDEX BY PLS_INTEGER; +e_custom EXCEPTION; +PRAGMA EXCEPTION_INIT(e_custom, -20001); +BEGIN +NULL; +END; +/ + +[expect] +DECLARE + c_limit CONSTANT NUMBER := 10; + v_name employees.last_name%TYPE; + v_row employees%ROWTYPE; + CURSOR c_emp IS SELECT * FROM employees WHERE department_id = 10; + TYPE t_names IS TABLE OF VARCHAR2(100) INDEX BY PLS_INTEGER; + e_custom EXCEPTION; + PRAGMA EXCEPTION_INIT(e_custom, -20001); +BEGIN + NULL; +END; +/ + +== should format loops == +BEGIN +FOR i IN 1..10 LOOP DBMS_OUTPUT.PUT_LINE(i); END LOOP; +FOR r IN (SELECT * FROM employees) LOOP DBMS_OUTPUT.PUT_LINE(r.last_name); END LOOP; +FOR r IN c_emp LOOP NULL; END LOOP; +WHILE v_count < 10 LOOP v_count := v_count + 1; END LOOP; +LOOP FETCH c INTO v; EXIT WHEN c%NOTFOUND; END LOOP; +END; +/ + +[expect] +BEGIN + FOR i IN 1..10 LOOP + DBMS_OUTPUT.PUT_LINE(i); + END LOOP; + FOR r IN (SELECT * FROM employees) LOOP + DBMS_OUTPUT.PUT_LINE(r.last_name); + END LOOP; + FOR r IN c_emp LOOP + NULL; + END LOOP; + WHILE v_count < 10 LOOP + v_count := v_count + 1; + END LOOP; + LOOP + FETCH c INTO v; + EXIT WHEN c%NOTFOUND; + END LOOP; +END; +/ + +== should format if statements == +BEGIN +IF v_a > 0 THEN v_b := 1; ELSIF v_a < 0 THEN v_b := -1; ELSE v_b := 0; END IF; +END; +/ + +[expect] +BEGIN + IF v_a > 0 THEN + v_b := 1; + ELSIF v_a < 0 THEN + v_b := -1; + ELSE + v_b := 0; + END IF; +END; +/ + +== should format a case statement == +BEGIN +CASE v_grade WHEN 'A' THEN DBMS_OUTPUT.PUT_LINE('excellent'); WHEN 'B' THEN DBMS_OUTPUT.PUT_LINE('good'); ELSE DBMS_OUTPUT.PUT_LINE('other'); END CASE; +END; +/ + +[expect] +BEGIN + CASE v_grade + WHEN 'A' THEN + DBMS_OUTPUT.PUT_LINE('excellent'); + WHEN 'B' THEN + DBMS_OUTPUT.PUT_LINE('good'); + ELSE + DBMS_OUTPUT.PUT_LINE('other'); + END CASE; +END; +/ + +== should format a trigger == +CREATE OR REPLACE TRIGGER trg_bi BEFORE INSERT ON t FOR EACH ROW DECLARE v NUMBER; BEGIN SELECT seq.NEXTVAL INTO :NEW.id FROM dual; END; +/ + +[expect] +CREATE OR REPLACE TRIGGER trg_bi BEFORE INSERT ON t FOR EACH ROW +DECLARE + v NUMBER; +BEGIN + SELECT seq.NEXTVAL + INTO :NEW.id + FROM dual; +END; +/ + +== should format a trigger without declarations == +CREATE OR REPLACE TRIGGER trg_bu BEFORE UPDATE ON t FOR EACH ROW WHEN (NEW.a > 0) BEGIN :NEW.updated := SYSDATE; END; +/ + +[expect] +CREATE OR REPLACE TRIGGER trg_bu BEFORE UPDATE ON t FOR EACH ROW WHEN (NEW.a > 0) +BEGIN + :NEW.updated := SYSDATE; +END; +/ + +== should format bulk collect and forall == +DECLARE TYPE t_ids IS TABLE OF NUMBER; v_ids t_ids; +BEGIN +SELECT id BULK COLLECT INTO v_ids FROM t; +FORALL i IN 1..v_ids.COUNT DELETE FROM u WHERE id = v_ids(i); +END; +/ + +[expect] +DECLARE + TYPE t_ids IS TABLE OF NUMBER; + v_ids t_ids; +BEGIN + SELECT id + BULK COLLECT INTO v_ids + FROM t; + FORALL i IN 1..v_ids.COUNT DELETE FROM u WHERE id = v_ids(i); +END; +/ + +== should format execute immediate == +BEGIN EXECUTE IMMEDIATE 'DROP TABLE t'; EXCEPTION WHEN OTHERS THEN NULL; END; +/ + +[expect] +BEGIN + EXECUTE IMMEDIATE 'DROP TABLE t'; +EXCEPTION + WHEN OTHERS THEN + NULL; +END; +/ diff --git a/tests/specs/Oracle/Syntax.txt b/tests/specs/Oracle/Syntax.txt new file mode 100644 index 0000000..2db3ef1 --- /dev/null +++ b/tests/specs/Oracle/Syntax.txt @@ -0,0 +1,149 @@ +~~ dialect: oracle ~~ +== should format quoted strings == +SELECT q'[it's]', Q'{a}', q'!b!', nq'(c)' FROM dual; + +[expect] +SELECT q'[it's]', Q'{a}', q'!b!', nq'(c)' +FROM dual; + +== should format bind variables == +SELECT * FROM t WHERE a = :a AND b = :1; + +[expect] +SELECT * +FROM t +WHERE a = :a AND b = :1; + +== should format outer joins == +SELECT * FROM a, b WHERE a.id = b.id(+); + +[expect] +SELECT * +FROM a, b +WHERE a.id = b.id(+); + +== should format hierarchical queries == +SELECT LEVEL, e.name FROM employees e START WITH e.manager_id IS NULL CONNECT BY PRIOR e.id = e.manager_id ORDER SIBLINGS BY e.name; + +[expect] +SELECT LEVEL, e.name +FROM employees e +START WITH e.manager_id IS NULL +CONNECT BY PRIOR e.id = e.manager_id +ORDER SIBLINGS BY e.name; + +== should format sequences == +SELECT seq.NEXTVAL, seq.CURRVAL FROM dual; + +[expect] +SELECT seq.NEXTVAL, seq.CURRVAL +FROM dual; + +== should format sql plus commands == +SET SERVEROUTPUT ON +SET DEFINE OFF +PROMPT Don't stop now +BEGIN DBMS_OUTPUT.PUT_LINE('hi'); END; +/ +@other_script.sql +SPOOL off + +[expect] +SET SERVEROUTPUT ON +SET DEFINE OFF +PROMPT Don't stop now +BEGIN + DBMS_OUTPUT.PUT_LINE('hi'); +END; +/ +@other_script.sql +SPOOL off + +== should format the slash separator after statements == +CREATE TABLE t (a NUMBER) +/ +INSERT INTO t VALUES (1) +/ + +[expect] +CREATE TABLE t (a NUMBER) +/ +INSERT INTO t +VALUES (1) +/ + +== should format substitution variables == +SELECT * FROM t WHERE a = &value AND b = &&other; + +[expect] +SELECT * +FROM t +WHERE a = &value AND b = &&other; + +== should format database links == +SELECT * FROM employees@remote_db; + +[expect] +SELECT * +FROM employees@remote_db; + +== should format merge == +MERGE INTO t USING s ON (t.id = s.id) WHEN MATCHED THEN UPDATE SET t.a = s.a WHEN NOT MATCHED THEN INSERT (id, a) VALUES (s.id, s.a); + +[expect] +MERGE INTO t +USING s +ON (t.id = s.id) +WHEN MATCHED THEN + UPDATE SET t.a = s.a +WHEN NOT MATCHED THEN + INSERT (id, a) VALUES (s.id, s.a); + +== should format returning into == +INSERT INTO t (a) VALUES (1) RETURNING id INTO v_id; + +[expect] +INSERT INTO t (a) +VALUES (1) +RETURNING id INTO v_id; + +== should keep sql plus commands on their lines == +BEGIN NULL; END; +/ +SHOW ERRORS +CREATE OR REPLACE PROCEDURE b IS BEGIN NULL; END; +/ +EXEC a +COLUMN name FORMAT a20 +DESC t +SELECT 1 FROM dual; + +[expect] +BEGIN + NULL; +END; +/ +SHOW ERRORS +CREATE OR REPLACE PROCEDURE b IS +BEGIN + NULL; +END; +/ +EXEC a +COLUMN name FORMAT a20 +DESC t +SELECT 1 +FROM dual; + +== should continue a sql plus command after a dash == +EXECUTE dbms_stats.gather_schema_stats( - + 'OE', - + cascade => TRUE); +SELECT 1 FROM dual; + +[expect] +EXECUTE dbms_stats.gather_schema_stats( - + 'OE', - + cascade => TRUE); +SELECT 1 +FROM dual; diff --git a/tests/specs/PostgreSql/Functions.txt b/tests/specs/PostgreSql/Functions.txt new file mode 100644 index 0000000..1f5c87e --- /dev/null +++ b/tests/specs/PostgreSql/Functions.txt @@ -0,0 +1,381 @@ +~~ dialect: postgresql ~~ +== should format a plpgsql function and its body == +create or replace function public.increment(i integer) returns integer language plpgsql as $$ +declare result integer; +begin +result := i + 1; +return result; +end; +$$; + +[expect] +create or replace function public.increment(i integer) +returns integer +language plpgsql +as $$ +declare + result integer; +begin + result := i + 1; + return result; +end; +$$; + +== should format if statements == +CREATE FUNCTION sign_of(x int) RETURNS text AS $$ +BEGIN +IF x > 0 THEN RETURN 'positive'; ELSIF x < 0 THEN RETURN 'negative'; ELSE RETURN 'zero'; END IF; +END; +$$ LANGUAGE plpgsql; + +[expect] +CREATE FUNCTION sign_of(x int) +RETURNS text +AS $$ +BEGIN + IF x > 0 THEN + RETURN 'positive'; + ELSIF x < 0 THEN + RETURN 'negative'; + ELSE + RETURN 'zero'; + END IF; +END; +$$ +LANGUAGE plpgsql; + +== should format loops == +CREATE FUNCTION loops() RETURNS void AS $body$ +DECLARE r record; i int := 0; +BEGIN +LOOP i := i + 1; EXIT WHEN i > 10; END LOOP; +WHILE i > 0 LOOP i := i - 1; END LOOP; +FOR i IN 1..10 LOOP RAISE NOTICE '%', i; END LOOP; +FOR i IN REVERSE 10..1 BY 2 LOOP CONTINUE WHEN i = 5; END LOOP; +FOR r IN SELECT * FROM t WHERE t.active LOOP PERFORM do_something(r.id); END LOOP; +FOREACH i IN ARRAY ARRAY[1, 2, 3] LOOP NULL; END LOOP; +END; +$body$ LANGUAGE plpgsql; + +[expect] +CREATE FUNCTION loops() +RETURNS void +AS $body$ +DECLARE + r record; + i int := 0; +BEGIN + LOOP + i := i + 1; + EXIT WHEN i > 10; + END LOOP; + WHILE i > 0 LOOP + i := i - 1; + END LOOP; + FOR i IN 1..10 LOOP + RAISE NOTICE '%', i; + END LOOP; + FOR i IN REVERSE 10..1 BY 2 LOOP + CONTINUE WHEN i = 5; + END LOOP; + FOR r IN SELECT * FROM t WHERE t.active LOOP + PERFORM do_something(r.id); + END LOOP; + FOREACH i IN ARRAY ARRAY[1, 2, 3] LOOP + NULL; + END LOOP; +END; +$body$ +LANGUAGE plpgsql; + +== should format a loop over a long query == +CREATE FUNCTION f() RETURNS void AS $$ +DECLARE r record; +BEGIN +FOR r IN SELECT id, name, email, created_at FROM users WHERE active = TRUE AND deleted_at IS NULL ORDER BY created_at LOOP +PERFORM notify(r.id); +END LOOP; +END; +$$ LANGUAGE plpgsql; + +[expect] +CREATE FUNCTION f() +RETURNS void +AS $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT id, name, email, created_at + FROM users + WHERE active = TRUE AND deleted_at IS NULL + ORDER BY created_at + LOOP + PERFORM notify(r.id); + END LOOP; +END; +$$ +LANGUAGE plpgsql; + +== should format exception handlers == +CREATE FUNCTION safe_divide(a numeric, b numeric) RETURNS numeric AS $$ +BEGIN +RETURN a / b; +EXCEPTION WHEN division_by_zero THEN RAISE NOTICE 'division by zero'; RETURN NULL; +WHEN OTHERS THEN RAISE; +END; +$$ LANGUAGE plpgsql; + +[expect] +CREATE FUNCTION safe_divide(a numeric, b numeric) +RETURNS numeric +AS $$ +BEGIN + RETURN a / b; +EXCEPTION + WHEN division_by_zero THEN + RAISE NOTICE 'division by zero'; + RETURN NULL; + WHEN OTHERS THEN + RAISE; +END; +$$ +LANGUAGE plpgsql; + +== should format a case statement == +CREATE FUNCTION describe(x int) RETURNS text AS $$ +BEGIN +CASE x WHEN 1 THEN RETURN 'one'; WHEN 2 THEN RETURN 'two'; ELSE RETURN 'many'; END CASE; +END; +$$ LANGUAGE plpgsql; + +[expect] +CREATE FUNCTION describe(x int) +RETURNS text +AS $$ +BEGIN + CASE x + WHEN 1 THEN + RETURN 'one'; + WHEN 2 THEN + RETURN 'two'; + ELSE + RETURN 'many'; + END CASE; +END; +$$ +LANGUAGE plpgsql; + +== should format nested blocks and labels == +CREATE FUNCTION nested() RETURNS void AS $$ +<> +DECLARE x int := 1; +BEGIN +DECLARE x int := 2; +BEGIN +RAISE NOTICE '% %', outer.x, x; +END; +END; +$$ LANGUAGE plpgsql; + +[expect] +CREATE FUNCTION nested() +RETURNS void +AS $$ +<> +DECLARE + x int := 1; +BEGIN + DECLARE + x int := 2; + BEGIN + RAISE NOTICE '% %', outer.x, x; + END; +END; +$$ +LANGUAGE plpgsql; + +== should format return query == +CREATE FUNCTION active_users() RETURNS TABLE (id int, name text) AS $$ +BEGIN +RETURN QUERY SELECT u.id, u.name FROM users u WHERE u.active; +END; +$$ LANGUAGE plpgsql STABLE; + +[expect] +CREATE FUNCTION active_users() +RETURNS TABLE (id int, name text) +AS $$ +BEGIN + RETURN QUERY SELECT u.id, u.name FROM users u WHERE u.active; +END; +$$ +LANGUAGE plpgsql STABLE; + +== should format a sql function == +CREATE FUNCTION add(a int, b int) RETURNS int AS $$ SELECT a + b $$ LANGUAGE sql IMMUTABLE STRICT; + +[expect] +CREATE FUNCTION add(a int, b int) +RETURNS int +AS $$ SELECT a + b $$ +LANGUAGE sql IMMUTABLE STRICT; + +== should format a sql function body over several lines == +CREATE FUNCTION active_count() RETURNS bigint LANGUAGE sql AS $$ +select count(*) from users where active +$$; + +[expect] +CREATE FUNCTION active_count() +RETURNS bigint +LANGUAGE sql +AS $$ +select count(*) +from users +where active +$$; + +== should format a standard sql body == +CREATE FUNCTION add(a int, b int) RETURNS int LANGUAGE sql IMMUTABLE BEGIN ATOMIC SELECT a + b; END; + +[expect] +CREATE FUNCTION add(a int, b int) +RETURNS int +LANGUAGE sql IMMUTABLE +BEGIN ATOMIC + SELECT a + b; +END; + +== should format return as a body == +CREATE FUNCTION add(a int, b int) RETURNS int LANGUAGE sql RETURN a + b; + +[expect] +CREATE FUNCTION add(a int, b int) +RETURNS int +LANGUAGE sql +RETURN a + b; + +== should leave the body of another language alone == +CREATE FUNCTION py() RETURNS int AS $$ + return 1 +$$ LANGUAGE plpython3u; + +[expect] +CREATE FUNCTION py() +RETURNS int +AS $$ + return 1 +$$ +LANGUAGE plpython3u; + +== should leave a body in a string alone == +CREATE FUNCTION f() RETURNS int AS 'select 1' LANGUAGE sql; + +[expect] +CREATE FUNCTION f() +RETURNS int +AS 'select 1' +LANGUAGE sql; + +== should format a trigger function == +CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ +BEGIN NEW.updated_at := now(); RETURN NEW; END; +$$ LANGUAGE plpgsql; + +[expect] +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS trigger +AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$ +LANGUAGE plpgsql; + +== should format a function with many parameters == +CREATE FUNCTION make_user(p_first_name text, p_last_name text, p_email text, p_phone text DEFAULT NULL) RETURNS uuid AS $$ +BEGIN +RETURN gen_random_uuid(); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +[expect] +CREATE FUNCTION make_user(p_first_name text, p_last_name text, p_email text, p_phone text DEFAULT NULL) +RETURNS uuid +AS $$ +BEGIN + RETURN gen_random_uuid(); +END; +$$ +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +== should format dynamic sql == +CREATE FUNCTION count_rows(tbl text) RETURNS bigint AS $$ +DECLARE n bigint; +BEGIN +EXECUTE format('SELECT count(*) FROM %I', tbl) INTO n; +RETURN n; +END; +$$ LANGUAGE plpgsql; + +[expect] +CREATE FUNCTION count_rows(tbl text) +RETURNS bigint +AS $$ +DECLARE + n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM %I', tbl) INTO n; + RETURN n; +END; +$$ +LANGUAGE plpgsql; + +== should format nested dollar quotes == +CREATE FUNCTION f() RETURNS void AS $outer$ +BEGIN +EXECUTE $inner$ SELECT 1 $inner$; +END; +$outer$ LANGUAGE plpgsql; + +[expect] +CREATE FUNCTION f() +RETURNS void +AS $outer$ +BEGIN + EXECUTE $inner$ SELECT 1 $inner$; +END; +$outer$ +LANGUAGE plpgsql; + +== should format select into and get diagnostics == +CREATE FUNCTION f() RETURNS int AS $$ +DECLARE n int; c int; +BEGIN +SELECT count(*) INTO STRICT n FROM t; +UPDATE t SET a = 1; +GET DIAGNOSTICS c = ROW_COUNT; +RETURN n + c; +END; +$$ LANGUAGE plpgsql; + +[expect] +CREATE FUNCTION f() +RETURNS int +AS $$ +DECLARE + n int; + c int; +BEGIN + SELECT count(*) + INTO STRICT n + FROM t; + UPDATE t + SET a = 1; + GET DIAGNOSTICS c = ROW_COUNT; + RETURN n + c; +END; +$$ +LANGUAGE plpgsql; diff --git a/tests/specs/PostgreSql/Procedures.txt b/tests/specs/PostgreSql/Procedures.txt new file mode 100644 index 0000000..03edead --- /dev/null +++ b/tests/specs/PostgreSql/Procedures.txt @@ -0,0 +1,84 @@ +~~ dialect: postgresql ~~ +== should format a procedure == +CREATE OR REPLACE PROCEDURE transfer(from_id int, to_id int, amount numeric) LANGUAGE plpgsql AS $$ +BEGIN +UPDATE accounts SET balance = balance - amount WHERE id = from_id; +UPDATE accounts SET balance = balance + amount WHERE id = to_id; +COMMIT; +END; +$$; + +[expect] +CREATE OR REPLACE PROCEDURE transfer(from_id int, to_id int, amount numeric) +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE accounts + SET balance = balance - amount + WHERE id = from_id; + UPDATE accounts + SET balance = balance + amount + WHERE id = to_id; + COMMIT; +END; +$$; + +== should format call == +CALL transfer(1, 2, 100.00); + +[expect] +CALL transfer(1, 2, 100.00); + +== should format a do block == +DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app') THEN CREATE ROLE app; END IF; END $$; + +[expect] +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app') THEN + CREATE ROLE app; + END IF; +END +$$; + +== should format a do block with a language == +DO LANGUAGE plpgsql $$ BEGIN RAISE NOTICE 'hi'; END $$; + +[expect] +DO LANGUAGE plpgsql $$ +BEGIN + RAISE NOTICE 'hi'; +END +$$; + +== should leave a do block in another language alone == +DO LANGUAGE plpython3u $$ +print( 1 ) +$$; + +[expect] +DO LANGUAGE plpython3u $$ +print( 1 ) +$$; + +== should format a trigger == +CREATE TRIGGER set_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +[expect] +CREATE TRIGGER set_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +== should format a rule == +CREATE RULE r AS ON INSERT TO t DO INSTEAD (INSERT INTO u VALUES (NEW.a); INSERT INTO v VALUES (NEW.a)); + +[expect] +CREATE RULE r AS ON INSERT TO t DO INSTEAD (INSERT INTO u VALUES (NEW.a); INSERT INTO v VALUES (NEW.a)); + +== should format a cursor == +DECLARE c CURSOR FOR SELECT * FROM t WHERE a = 1; +FETCH NEXT FROM c; +CLOSE c; + +[expect] +DECLARE c CURSOR FOR SELECT * FROM t WHERE a = 1; +FETCH NEXT FROM c; +CLOSE c; diff --git a/tests/specs/PostgreSql/Psql.txt b/tests/specs/PostgreSql/Psql.txt new file mode 100644 index 0000000..2333c82 --- /dev/null +++ b/tests/specs/PostgreSql/Psql.txt @@ -0,0 +1,67 @@ +~~ dialect: postgresql ~~ +== should format meta-commands after a query == +SELECT 1 AS x \gset +SELECT :x; + +[expect] +SELECT 1 AS x +\gset +SELECT :x; + +== should format pipelines == +\startpipeline +SELECT $1 \bind 'val1' \sendpipeline +BEGIN \bind \sendpipeline +INSERT INTO t VALUES ($1) \bind 1 \sendpipeline +COMMIT \bind \sendpipeline +\endpipeline + +[expect] +\startpipeline +SELECT $1 +\bind 'val1' \sendpipeline +BEGIN +\bind \sendpipeline +INSERT INTO t +VALUES ($1) +\bind 1 \sendpipeline +COMMIT +\bind \sendpipeline +\endpipeline + +== should format several statements sent together == +copy (select 1) to stdout\; select 1/0; +select 0\; copy t from stdin\; copy t from stdin\; select 1; -- 0 1 +1 +\. +2 +\. +select * from t; + +[expect] +copy (select 1) to stdout\; +select 1 / 0; +select 0\; +copy t from stdin\; +copy t from stdin\; +select 1; -- 0 1 +1 +\. +2 +\. +select * +from t; + +== should format conditionals == +\if :is_superuser +SELECT 1; +\else +SELECT 2; +\endif + +[expect] +\if :is_superuser +SELECT 1; +\else +SELECT 2; +\endif diff --git a/tests/specs/PostgreSql/Syntax.txt b/tests/specs/PostgreSql/Syntax.txt new file mode 100644 index 0000000..89d55d3 --- /dev/null +++ b/tests/specs/PostgreSql/Syntax.txt @@ -0,0 +1,150 @@ +~~ dialect: postgresql ~~ +== should format operators == +SELECT data->>'name', data->'a'->'b', data#>>'{a,b}', tags@>ARRAY['x'], tags<@ARRAY['y'], doc@@query, a||b, a<->b FROM t WHERE data?'key' AND data?|ARRAY['a'] AND name~*'^a' AND name!~'b'; + +[expect] +SELECT + data ->> 'name', + data -> 'a' -> 'b', + data #>> '{a,b}', + tags @> ARRAY['x'], + tags <@ ARRAY['y'], + doc @@ query, + a || b, + a <-> b +FROM t +WHERE data ? 'key' AND data ?| ARRAY['a'] AND name ~* '^a' AND name !~ 'b'; + +== should format casts == +SELECT a::int, '1'::numeric(10,2), b::text[], (c + d)::bigint, now()::date FROM t; + +[expect] +SELECT a::int, '1'::numeric(10, 2), b::text[], (c + d)::bigint, now()::date +FROM t; + +== should format dollar quoted strings == +SELECT $$it's$$, $tag$a $$ b$tag$; + +[expect] +SELECT $$it's$$, $tag$a $$ b$tag$; + +== should format arrays == +SELECT ARRAY[1, 2, 3], a[1], a[1:2], a[:2], ARRAY[[1, 2], [3, 4]] FROM t; + +[expect] +SELECT ARRAY[1, 2, 3], a[1], a[1:2], a[:2], ARRAY[[1, 2], [3, 4]] +FROM t; + +== should format escape strings == +SELECT E'line\nbreak', E'it\'s', U&'d\0061t\+000061'; + +[expect] +SELECT E'line\nbreak', E'it\'s', U&'d\0061t\+000061'; + +== should format nested comments == +SELECT 1 /* outer /* inner */ still a comment */; + +[expect] +SELECT 1 /* outer /* inner */ still a comment */; + +== should format psql meta commands == +\set ON_ERROR_STOP on +\connect mydb +SELECT 1; +\i other.sql + +[expect] +\set ON_ERROR_STOP on +\connect mydb +SELECT 1; +\i other.sql + +== should format psql variables == +SELECT * FROM t WHERE id = :id AND name = :'name' AND col = :"column"; + +[expect] +SELECT * +FROM t +WHERE id = :id AND name = :'name' AND col = :"column"; + +== should format copy data == +COPY t (a, b) FROM stdin; +1 foo +2 bar +\. +SELECT 1; + +[expect] +COPY t (a, b) FROM stdin; +1 foo +2 bar +\. +SELECT 1; + +== should format psql's escaped semicolon == +SELECT 1 \; SELECT 2; + +[expect] +SELECT 1\; +SELECT 2; + +== should format on conflict == +INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT ON CONSTRAINT t_pkey DO UPDATE SET b = EXCLUDED.b; + +[expect] +INSERT INTO t (a, b) +VALUES (1, 2) +ON CONFLICT ON CONSTRAINT t_pkey DO UPDATE +SET b = EXCLUDED.b; + +== should format distinct on and filter == +SELECT DISTINCT ON (a) a, count(*) FILTER (WHERE b) FROM t GROUP BY a ORDER BY a; + +[expect] +SELECT DISTINCT ON (a) a, count(*) FILTER (WHERE b) +FROM t +GROUP BY a +ORDER BY a; + +== should format returning == +DELETE FROM t WHERE a < now() - INTERVAL '30 days' RETURNING *; + +[expect] +DELETE FROM t +WHERE a < now() - INTERVAL '30 days' +RETURNING *; + +== should format the hash operator == +SELECT a # b, a #- '{x}' FROM t; + +[expect] +SELECT a # b, a #- '{x}' +FROM t; + +== should format on conflict do select == +INSERT INTO t (a) VALUES (2) ON CONFLICT (a) DO SELECT WHERE t.b = 1 RETURNING t.a; + +[expect] +INSERT INTO t (a) +VALUES (2) +ON CONFLICT (a) DO SELECT +WHERE t.b = 1 +RETURNING t.a; + +== should keep unicode whitespace in a name == +SELECT a b, c d FROM t; + +[expect] +SELECT a b, c d +FROM t; + +== should keep the line break between strings that are joined == +select 'foo' +'bar' as x, 'a' 'b' as y from t; + +[expect] +select + 'foo' + 'bar' as x, + 'a' 'b' as y +from t; diff --git a/tests/specs/Range/Range_All.txt b/tests/specs/Range/Range_All.txt new file mode 100644 index 0000000..908077d --- /dev/null +++ b/tests/specs/Range/Range_All.txt @@ -0,0 +1,77 @@ +== should format only the statement the range is in == +select 1; +select [|2|]; +select 3; + +[expect] +select 1; +select 2; +select 3; + +== should format the statements the range touches == +select 1; +select [|2; +select|] 3; +select 4; + +[expect] +select 1; +select 2; +select 3; +select 4; + +== should format the statement the cursor is in == +select 1; +select 2 [||] from t; +select 3; + +[expect] +select 1; +select 2 +from t; +select 3; + +== should format a whole procedure when within it == +select 1; +create procedure p() begin +select [||]2; +end; +select 3; + +[expect] +select 1; +create procedure p() +begin + select 2; +end; +select 3; + +== should format nothing when the range only touches blank lines == +select 1; +[| +|] +select 2; + +[expect] +select 1; + + +select 2; + +== should keep the blank lines around the range == +select 1; + + +select [|2|]; + + +select 3; + +[expect] +select 1; + + +select 2; + + +select 3; diff --git a/tests/specs/Select/Case.txt b/tests/specs/Select/Case.txt new file mode 100644 index 0000000..8237f13 --- /dev/null +++ b/tests/specs/Select/Case.txt @@ -0,0 +1,87 @@ +== should keep a short case on one line == +SELECT CASE WHEN a = 1 THEN 'one' ELSE 'other' END FROM t; + +[expect] +SELECT CASE WHEN a = 1 THEN 'one' ELSE 'other' END +FROM t; + +== should format a simple case == +SELECT CASE status WHEN 1 THEN 'active' WHEN 2 THEN 'inactive' ELSE 'unknown' END AS status_name FROM t; + +[expect] +SELECT CASE status WHEN 1 THEN 'active' WHEN 2 THEN 'inactive' ELSE 'unknown' END AS status_name +FROM t; + +== should keep a case the author wrote over several lines == +SELECT CASE +WHEN a = 1 THEN 'one' +WHEN a = 2 THEN 'two' +END AS n FROM t; + +[expect] +SELECT + CASE + WHEN a = 1 THEN 'one' + WHEN a = 2 THEN 'two' + END AS n +FROM t; + +== should format nested cases == +SELECT CASE WHEN a = 1 THEN CASE WHEN b = 1 THEN 'x' ELSE 'y' END ELSE 'z' END FROM t; + +[expect] +SELECT CASE WHEN a = 1 THEN CASE WHEN b = 1 THEN 'x' ELSE 'y' END ELSE 'z' END +FROM t; + +== should format nested cases over several lines == +SELECT + CASE + WHEN a = 1 THEN + CASE + WHEN b = 1 THEN 'x' + ELSE 'y' + END + ELSE 'z' + END +FROM t; + +[expect] +SELECT + CASE + WHEN a = 1 THEN CASE + WHEN b = 1 THEN 'x' + ELSE 'y' + END + ELSE 'z' + END +FROM t; + +== should format a case in where == +SELECT * FROM t WHERE CASE WHEN a = 1 THEN b ELSE c END = 2; + +[expect] +SELECT * +FROM t +WHERE CASE WHEN a = 1 THEN b ELSE c END = 2; + +== should format a case in order by == +SELECT * FROM t ORDER BY CASE WHEN a IS NULL THEN 1 ELSE 0 END, a; + +[expect] +SELECT * +FROM t +ORDER BY CASE WHEN a IS NULL THEN 1 ELSE 0 END, a; + +== should format a case in a function call == +SELECT sum(CASE WHEN a > 0 THEN a ELSE 0 END) AS positive FROM t; + +[expect] +SELECT sum(CASE WHEN a > 0 THEN a ELSE 0 END) AS positive +FROM t; + +== should format a case without else == +SELECT CASE WHEN a THEN 1 END FROM t; + +[expect] +SELECT CASE WHEN a THEN 1 END +FROM t; diff --git a/tests/specs/Select/Expressions.txt b/tests/specs/Select/Expressions.txt new file mode 100644 index 0000000..f37bfe5 --- /dev/null +++ b/tests/specs/Select/Expressions.txt @@ -0,0 +1,195 @@ +== should put spaces around binary operators == +SELECT a+b, a-b, a*b, a/b, a% b, a||b, a=b, a<>b, a!=b, ab, a>=b FROM t; + +[expect] +SELECT a + b, a - b, a * b, a / b, a % b, a || b, a = b, a <> b, a != b, a < b, a <= b, a > b, a >= b +FROM t; + +== should keep a percent sign written against both sides as it is == +SELECT a%b, a % b FROM t; + +[expect] +SELECT a%b, a % b +FROM t; + +== should not put a space after a unary operator == +SELECT -a, - b, +c, ~d, a * -1, a = -1, -(a + b), NOT a FROM t WHERE x = - 1; + +[expect] +SELECT -a, -b, +c, ~d, a * -1, a = -1, -(a + b), NOT a +FROM t +WHERE x = -1; + +== should tell unary from binary after keywords == +SELECT CASE WHEN a THEN -1 ELSE -2 END, b - 1, NULL - 1 FROM t LIMIT -1; + +[expect] +SELECT CASE WHEN a THEN -1 ELSE -2 END, b - 1, NULL - 1 +FROM t +LIMIT -1; + +== should format the wildcard == +SELECT *, t.*, count(*) FROM t; + +[expect] +SELECT *, t.*, count(*) +FROM t; + +== should format casts == +SELECT CAST(a AS INT), a::int, b::numeric(10, 2), c::text[] FROM t; + +[expect] +SELECT CAST(a AS INT), a::int, b::numeric(10, 2), c::text[] +FROM t; + +== should format between == +SELECT * FROM t WHERE a BETWEEN 1 AND 10 AND b NOT BETWEEN 2 AND 3; + +[expect] +SELECT * +FROM t +WHERE a BETWEEN 1 AND 10 AND b NOT BETWEEN 2 AND 3; + +== should format like == +SELECT * FROM t WHERE a LIKE 'x%' AND b NOT LIKE '%y' AND c ILIKE 'z' AND d LIKE 'a!%' ESCAPE '!'; + +[expect] +SELECT * +FROM t +WHERE a LIKE 'x%' AND b NOT LIKE '%y' AND c ILIKE 'z' AND d LIKE 'a!%' ESCAPE '!'; + +== should format is null == +SELECT * FROM t WHERE a IS NULL AND b IS NOT NULL AND c IS DISTINCT FROM d; + +[expect] +SELECT * +FROM t +WHERE a IS NULL AND b IS NOT NULL AND c IS DISTINCT FROM d; + +== should format in lists == +SELECT * FROM t WHERE a IN (1, 2, 3) AND b NOT IN ('x', 'y'); + +[expect] +SELECT * +FROM t +WHERE a IN (1, 2, 3) AND b NOT IN ('x', 'y'); + +== should format function calls == +SELECT count(*), coalesce(a, b, 0), upper(trim(name)), now() FROM t; + +[expect] +SELECT count(*), coalesce(a, b, 0), upper(trim(name)), now() +FROM t; + +== should keep the spacing before a function's brackets == +SELECT f (a), g(b) FROM t; + +[expect] +SELECT f (a), g(b) +FROM t; + +== should put a space before brackets after keywords == +SELECT * FROM t WHERE a IN(1, 2) AND EXISTS(SELECT 1); + +[expect] +SELECT * +FROM t +WHERE a IN (1, 2) AND EXISTS (SELECT 1); + +== should format special function syntax == +SELECT EXTRACT(YEAR FROM d), TRIM(BOTH ' ' FROM s), SUBSTRING(s FROM 1 FOR 2), POSITION('a' IN s), OVERLAY(s PLACING 'x' FROM 1) FROM t; + +[expect] +SELECT + EXTRACT(YEAR FROM d), + TRIM(BOTH ' ' FROM s), + SUBSTRING(s FROM 1 FOR 2), + POSITION('a' IN s), + OVERLAY(s PLACING 'x' FROM 1) +FROM t; + +== should format aggregate modifiers == +SELECT count(DISTINCT a), string_agg(b, ',' ORDER BY b), array_agg(c) FILTER (WHERE c > 0), percentile_cont(0.5) WITHIN GROUP (ORDER BY d) FROM t; + +[expect] +SELECT + count(DISTINCT a), + string_agg(b, ',' ORDER BY b), + array_agg(c) FILTER (WHERE c > 0), + percentile_cont(0.5) WITHIN GROUP (ORDER BY d) +FROM t; + +== should format windows == +SELECT row_number() OVER (PARTITION BY a ORDER BY b DESC), sum(c) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM t; + +[expect] +SELECT + row_number() OVER (PARTITION BY a ORDER BY b DESC), + sum(c) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) +FROM t; + +== should format intervals == +SELECT now() - INTERVAL '1 day', d + INTERVAL 3 MONTH FROM t; + +[expect] +SELECT now() - INTERVAL '1 day', d + INTERVAL 3 MONTH +FROM t; + +== should format strings == +SELECT 'it''s', N'unicode', E'esc\'aped', X'FF', B'0101' FROM t; + +[expect] +SELECT 'it''s', N'unicode', E'esc\'aped', X'FF', B'0101' +FROM t; + +== should format numbers == +SELECT 1, 1.5, .5, 1e10, 1.5E-3, 0xFF FROM t; + +[expect] +SELECT 1, 1.5, .5, 1e10, 1.5E-3, 0xFF +FROM t; + +== should format parameters == +SELECT * FROM t WHERE a = ? AND b = :b AND c = $1 AND d = @d; + +[expect] +SELECT * +FROM t +WHERE a = ? AND b = :b AND c = $1 AND d = @d; + +== should format collate == +SELECT * FROM t ORDER BY name COLLATE "C"; + +[expect] +SELECT * +FROM t +ORDER BY name COLLATE "C"; + +== should format array subscripts and constructors == +SELECT a[1], a[1:2], ARRAY[1, 2, 3], ARRAY(SELECT 1) FROM t; + +[expect] +SELECT a[1], a[1:2], ARRAY[1, 2, 3], ARRAY(SELECT 1) +FROM t; + +== should format row constructors == +SELECT * FROM t WHERE (a, b) = (1, 2) AND ROW(c, d) IS NOT NULL; + +[expect] +SELECT * +FROM t +WHERE (a, b) = (1, 2) AND ROW(c, d) IS NOT NULL; + +== should format bracketed expressions == +SELECT (a + b) * c, ((a)) FROM t; + +[expect] +SELECT (a + b) * c, ((a)) +FROM t; + +== should format quoted identifiers == +SELECT "Column Name", `other`, [bracketed] FROM "My Table"; + +[expect] +SELECT "Column Name", `other`, [bracketed] +FROM "My Table"; diff --git a/tests/specs/Select/Join.txt b/tests/specs/Select/Join.txt new file mode 100644 index 0000000..2dc033e --- /dev/null +++ b/tests/specs/Select/Join.txt @@ -0,0 +1,109 @@ +== should indent joins beneath from == +SELECT u.name, o.total FROM users u INNER JOIN orders o ON u.id = o.user_id WHERE o.total > 100 + +[expect] +SELECT u.name, o.total +FROM users u + INNER JOIN orders o ON u.id = o.user_id +WHERE o.total > 100 + +== should format every kind of join == +SELECT * FROM a JOIN b ON a.id = b.id INNER JOIN c ON c.id = a.id LEFT JOIN d ON d.id = a.id LEFT OUTER JOIN e ON e.id = a.id RIGHT JOIN f ON f.id = a.id RIGHT OUTER JOIN g ON g.id = a.id FULL JOIN h ON h.id = a.id FULL OUTER JOIN i ON i.id = a.id CROSS JOIN j; + +[expect] +SELECT * +FROM a + JOIN b ON a.id = b.id + INNER JOIN c ON c.id = a.id + LEFT JOIN d ON d.id = a.id + LEFT OUTER JOIN e ON e.id = a.id + RIGHT JOIN f ON f.id = a.id + RIGHT OUTER JOIN g ON g.id = a.id + FULL JOIN h ON h.id = a.id + FULL OUTER JOIN i ON i.id = a.id + CROSS JOIN j; + +== should format natural joins == +SELECT * FROM a NATURAL JOIN b NATURAL LEFT JOIN c; + +[expect] +SELECT * +FROM a + NATURAL JOIN b + NATURAL LEFT JOIN c; + +== should format using == +SELECT * FROM a JOIN b USING (id) JOIN c USING (id, kind); + +[expect] +SELECT * +FROM a + JOIN b USING (id) + JOIN c USING (id, kind); + +== should format apply == +SELECT * FROM a CROSS APPLY f(a.x) AS f OUTER APPLY g(a.y) AS g; + +[expect] +SELECT * +FROM a + CROSS APPLY f(a.x) AS f + OUTER APPLY g(a.y) AS g; + +== should format a join of a subquery == +SELECT * FROM a JOIN (SELECT id FROM b WHERE x = 1) AS b ON b.id = a.id; + +[expect] +SELECT * +FROM a + JOIN (SELECT id FROM b WHERE x = 1) AS b ON b.id = a.id; + +== should format join hints == +SELECT * FROM a INNER HASH JOIN b ON a.id = b.id LEFT MERGE JOIN c ON c.id = a.id; + +[expect] +SELECT * +FROM a + INNER HASH JOIN b ON a.id = b.id + LEFT MERGE JOIN c ON c.id = a.id; + +== should format comma joins == +SELECT * FROM a, b, c WHERE a.id = b.id AND b.id = c.id; + +[expect] +SELECT * +FROM a, b, c +WHERE a.id = b.id AND b.id = c.id; + +== should format a lateral join == +SELECT * FROM a CROSS JOIN LATERAL (SELECT * FROM b WHERE b.a_id = a.id) AS b; + +[expect] +SELECT * +FROM a + CROSS JOIN LATERAL (SELECT * FROM b WHERE b.a_id = a.id) AS b; + +== should format semi and anti joins == +SELECT * FROM a LEFT SEMI JOIN b ON a.id = b.id LEFT ANTI JOIN c ON a.id = c.id; + +[expect] +SELECT * +FROM a + LEFT SEMI JOIN b ON a.id = b.id + LEFT ANTI JOIN c ON a.id = c.id; + +== should format straight join == +SELECT * FROM a STRAIGHT_JOIN b ON a.id = b.id; + +[expect] +SELECT * +FROM a + STRAIGHT_JOIN b ON a.id = b.id; + +== should not read left as a join when it's a function == +SELECT LEFT(name, 3) FROM a LEFT JOIN b ON a.id = b.id; + +[expect] +SELECT LEFT(name, 3) +FROM a + LEFT JOIN b ON a.id = b.id; diff --git a/tests/specs/Select/Select_Basic.txt b/tests/specs/Select/Select_Basic.txt new file mode 100644 index 0000000..b6b0ef1 --- /dev/null +++ b/tests/specs/Select/Select_Basic.txt @@ -0,0 +1,215 @@ +== should format a simple select == +SELECT * FROM dbo.Test WHERE test = 'asdf' + +[expect] +SELECT * +FROM dbo.Test +WHERE test = 'asdf' + +== should put each clause on its own line == +select a, b from t where c = 1 group by a, b having count(*) > 1 order by a desc limit 10 offset 5; + +[expect] +select a, b +from t +where c = 1 +group by a, b +having count(*) > 1 +order by a desc +limit 10 +offset 5; + +== should keep a select without a from on one line == +SELECT 1; + +[expect] +SELECT 1; + +== should format distinct == +SELECT DISTINCT a, b FROM t; + +[expect] +SELECT DISTINCT a, b +FROM t; + +== should format distinct on == +SELECT DISTINCT ON (a) a, b FROM t ORDER BY a, b DESC; + +[expect] +SELECT DISTINCT ON (a) a, b +FROM t +ORDER BY a, b DESC; + +== should format top == +SELECT TOP 10 a FROM t; + +[expect] +SELECT TOP 10 a +FROM t; + +== should format top with brackets and percent == +SELECT TOP (10) PERCENT WITH TIES a FROM t ORDER BY a; + +[expect] +SELECT TOP (10) PERCENT WITH TIES a +FROM t +ORDER BY a; + +== should format all == +SELECT ALL a FROM t; + +[expect] +SELECT ALL a +FROM t; + +== should format aliases == +SELECT a AS x, b y, t.c FROM tbl AS t; + +[expect] +SELECT a AS x, b y, t.c +FROM tbl AS t; + +== should format qualified wildcards == +SELECT t.*, u.* FROM t, u; + +[expect] +SELECT t.*, u.* +FROM t, u; + +== should format multiple statements == +SELECT 1; SELECT 2; SELECT 3 + +[expect] +SELECT 1; +SELECT 2; +SELECT 3 + +== should keep statements without semicolons apart == +SELECT 1; +SELECT 2 + +[expect] +SELECT 1; +SELECT 2 + +== should format an empty statement == +; + +[expect] +; + +== should format into == +SELECT a, b INTO new_table FROM t; + +[expect] +SELECT a, b +INTO new_table +FROM t; + +== should format a long select list over several lines == +SELECT customer_id, first_name, last_name, email_address, phone_number, street_address, city, region, postal_code, country FROM customers; + +[expect] +SELECT + customer_id, + first_name, + last_name, + email_address, + phone_number, + street_address, + city, + region, + postal_code, + country +FROM customers; + +== should keep a short select list on one line == +SELECT + a, + b +FROM + t + +[expect] +SELECT a, b +FROM t + +== should format group by with rollup == +SELECT a, b, sum(c) FROM t GROUP BY ROLLUP (a, b); + +[expect] +SELECT a, b, sum(c) +FROM t +GROUP BY ROLLUP (a, b); + +== should format group by all == +SELECT a, count(*) FROM t GROUP BY ALL; + +[expect] +SELECT a, count(*) +FROM t +GROUP BY ALL; + +== should format order by with nulls == +SELECT a FROM t ORDER BY a ASC NULLS FIRST, b DESC NULLS LAST; + +[expect] +SELECT a +FROM t +ORDER BY a ASC NULLS FIRST, b DESC NULLS LAST; + +== should format fetch first == +SELECT a FROM t ORDER BY a OFFSET 10 ROWS FETCH FIRST 5 ROWS ONLY; + +[expect] +SELECT a +FROM t +ORDER BY a +OFFSET 10 ROWS +FETCH FIRST 5 ROWS ONLY; + +== should format for update == +SELECT a FROM t WHERE id = 1 FOR UPDATE SKIP LOCKED; + +[expect] +SELECT a +FROM t +WHERE id = 1 +FOR UPDATE SKIP LOCKED; + +== should format a window clause == +SELECT sum(a) OVER w FROM t WINDOW w AS (PARTITION BY b ORDER BY c); + +[expect] +SELECT sum(a) OVER w +FROM t +WINDOW w AS (PARTITION BY b ORDER BY c); + +== should format qualify == +SELECT a, row_number() OVER (PARTITION BY b ORDER BY c) AS rn FROM t QUALIFY rn = 1; + +[expect] +SELECT a, row_number() OVER (PARTITION BY b ORDER BY c) AS rn +FROM t +QUALIFY rn = 1; + +== should format a table statement == +TABLE users; + +[expect] +TABLE users; + +== should format values as a query == +VALUES (1, 'a'), (2, 'b'); + +[expect] +VALUES (1, 'a'), (2, 'b'); + +== should format explain == +EXPLAIN ANALYZE SELECT * FROM t WHERE id = 1; + +[expect] +EXPLAIN ANALYZE +SELECT * +FROM t +WHERE id = 1; diff --git a/tests/specs/Select/Select_LineWidth.txt b/tests/specs/Select/Select_LineWidth.txt new file mode 100644 index 0000000..d05ba02 --- /dev/null +++ b/tests/specs/Select/Select_LineWidth.txt @@ -0,0 +1,175 @@ +~~ lineWidth: 40 ~~ +== should break a select list that doesn't fit == +SELECT first_name, last_name, email, phone FROM users; + +[expect] +SELECT + first_name, + last_name, + email, + phone +FROM users; + +== should keep a select list that fits == +SELECT id, name FROM users; + +[expect] +SELECT id, name +FROM users; + +== should break where conditions that don't fit == +SELECT * FROM t WHERE first_condition = 1 AND second_condition = 2 OR third = 3; + +[expect] +SELECT * +FROM t +WHERE first_condition = 1 + AND second_condition = 2 + OR third = 3; + +== should not split the and of a between == +SELECT * FROM t WHERE created_at BETWEEN start_date AND end_date AND deleted = 0; + +[expect] +SELECT * +FROM t +WHERE created_at BETWEEN start_date AND end_date + AND deleted = 0; + +== should break a join's condition that doesn't fit == +SELECT * FROM a JOIN b ON b.a_id = a.id AND b.kind = 'x' AND b.flag = TRUE; + +[expect] +SELECT * +FROM a + JOIN b + ON b.a_id = a.id + AND b.kind = 'x' + AND b.flag = TRUE; + +== should break function arguments that don't fit == +SELECT coalesce(first_value, second_value, third_value) FROM t; + +[expect] +SELECT coalesce( + first_value, + second_value, + third_value +) +FROM t; + +== should break an in list that doesn't fit == +SELECT * FROM t WHERE id IN (1000, 2000, 3000, 4000, 5000, 6000); + +[expect] +SELECT * +FROM t +WHERE id IN ( + 1000, + 2000, + 3000, + 4000, + 5000, + 6000 +); + +== should break a subquery that doesn't fit == +SELECT * FROM t WHERE id IN (SELECT id FROM other WHERE active = 1); + +[expect] +SELECT * +FROM t +WHERE id IN ( + SELECT id + FROM other + WHERE active = 1 +); + +== should keep a lone subquery against its keyword == +SELECT * FROM (SELECT id, name FROM users WHERE active = 1) AS u; + +[expect] +SELECT * +FROM ( + SELECT id, name + FROM users + WHERE active = 1 +) AS u; + +== should break a case that doesn't fit == +SELECT CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' END AS n, b FROM t; + +[expect] +SELECT + CASE + WHEN a = 1 THEN 'one' + WHEN a = 2 THEN 'two' + END AS n, + b +FROM t; + +== should break values rows that don't fit == +INSERT INTO t (a, b) VALUES (1, 'one'), (2, 'two'), (3, 'three'); + +[expect] +INSERT INTO t (a, b) +VALUES + (1, 'one'), + (2, 'two'), + (3, 'three'); + +== should break group by and order by lists == +SELECT a, b, c FROM t GROUP BY first_column, second_column ORDER BY first_column, second_column; + +[expect] +SELECT a, b, c +FROM t +GROUP BY first_column, second_column +ORDER BY first_column, second_column; + +== should break nested groups from the outside in == +SELECT outer_function(inner_function(first_argument, second_argument), other) FROM t; + +[expect] +SELECT outer_function( + inner_function( + first_argument, + second_argument + ), + other +) +FROM t; + +== should break a window that doesn't fit == +SELECT row_number() OVER (PARTITION BY department ORDER BY salary DESC) AS rank FROM employees; + +[expect] +SELECT row_number() OVER ( + PARTITION BY department + ORDER BY salary DESC +) AS rank +FROM employees; + +== should break conditions in brackets == +SELECT * FROM t WHERE (first_condition = 1 OR second_condition = 2) AND x = 1; + +[expect] +SELECT * +FROM t +WHERE ( + first_condition = 1 + OR second_condition = 2 +) + AND x = 1; + +== should break a long cte == +WITH active_users AS (SELECT id, name FROM users WHERE active = 1) SELECT * FROM active_users; + +[expect] +WITH active_users AS ( + SELECT id, name + FROM users + WHERE active = 1 +) +SELECT * +FROM active_users; diff --git a/tests/specs/Select/Select_SetOperations.txt b/tests/specs/Select/Select_SetOperations.txt new file mode 100644 index 0000000..c947204 --- /dev/null +++ b/tests/specs/Select/Select_SetOperations.txt @@ -0,0 +1,106 @@ +== should put set operators on their own lines == +SELECT a FROM t UNION SELECT a FROM u; + +[expect] +SELECT a +FROM t +UNION +SELECT a +FROM u; + +== should format union all == +SELECT a FROM t UNION ALL SELECT a FROM u UNION ALL SELECT a FROM v; + +[expect] +SELECT a +FROM t +UNION ALL +SELECT a +FROM u +UNION ALL +SELECT a +FROM v; + +== should format intersect and except == +SELECT a FROM t INTERSECT SELECT a FROM u EXCEPT SELECT a FROM v; + +[expect] +SELECT a +FROM t +INTERSECT +SELECT a +FROM u +EXCEPT +SELECT a +FROM v; + +== should format minus == +SELECT a FROM t MINUS SELECT a FROM u; + +[expect] +SELECT a +FROM t +MINUS +SELECT a +FROM u; + +== should format union distinct == +SELECT a FROM t UNION DISTINCT SELECT a FROM u; + +[expect] +SELECT a +FROM t +UNION DISTINCT +SELECT a +FROM u; + +== should format queries in brackets == +(SELECT a FROM t) UNION (SELECT a FROM u) ORDER BY a; + +[expect] +(SELECT a FROM t) +UNION +(SELECT a FROM u) +ORDER BY a; + +== should format an order by after the set operations == +SELECT a FROM t UNION SELECT b FROM u ORDER BY 1 LIMIT 5; + +[expect] +SELECT a +FROM t +UNION +SELECT b +FROM u +ORDER BY 1 +LIMIT 5; + +== should format a set operation within a subquery == +SELECT * FROM (SELECT a FROM t UNION SELECT a FROM u) AS x; + +[expect] +SELECT * +FROM (SELECT a FROM t UNION SELECT a FROM u) AS x; + +== should not read bigquery's except as a set operator == +SELECT * EXCEPT (a, b) FROM t; + +[expect] +SELECT * EXCEPT (a, b) +FROM t; + +== should format every set operator alike (issue #3) == +SELECT unnest(new_tags) UNION SELECT unnest(previous_tags); +SELECT unnest(new_tags) INTERSECT SELECT unnest(previous_tags); +SELECT unnest(new_tags) EXCEPT SELECT unnest(previous_tags); + +[expect] +SELECT unnest(new_tags) +UNION +SELECT unnest(previous_tags); +SELECT unnest(new_tags) +INTERSECT +SELECT unnest(previous_tags); +SELECT unnest(new_tags) +EXCEPT +SELECT unnest(previous_tags); diff --git a/tests/specs/Select/Select_Subqueries.txt b/tests/specs/Select/Select_Subqueries.txt new file mode 100644 index 0000000..c686ab0 --- /dev/null +++ b/tests/specs/Select/Select_Subqueries.txt @@ -0,0 +1,104 @@ +== should keep a short subquery on one line == +SELECT * FROM t WHERE id IN (SELECT id FROM banned); + +[expect] +SELECT * +FROM t +WHERE id IN (SELECT id FROM banned); + +== should keep a subquery the author wrote over several lines == +SELECT * FROM t WHERE id IN ( + SELECT id FROM banned +); + +[expect] +SELECT * +FROM t +WHERE id IN ( + SELECT id + FROM banned +); + +== should format exists == +SELECT * FROM t WHERE EXISTS (SELECT 1 FROM u WHERE u.id = t.id); + +[expect] +SELECT * +FROM t +WHERE EXISTS (SELECT 1 FROM u WHERE u.id = t.id); + +== should format not exists == +SELECT * FROM t WHERE NOT EXISTS(SELECT 1 FROM u WHERE u.id = t.id); + +[expect] +SELECT * +FROM t +WHERE NOT EXISTS (SELECT 1 FROM u WHERE u.id = t.id); + +== should format a scalar subquery == +SELECT a, (SELECT max(b) FROM u WHERE u.a = t.a) AS max_b FROM t; + +[expect] +SELECT a, (SELECT max(b) FROM u WHERE u.a = t.a) AS max_b +FROM t; + +== should format a subquery in from == +SELECT x.a FROM (SELECT a FROM t) AS x; + +[expect] +SELECT x.a +FROM (SELECT a FROM t) AS x; + +== should format a subquery in from written over several lines == +SELECT x.a FROM ( +SELECT a FROM t WHERE b = 1 +) x WHERE x.a > 1; + +[expect] +SELECT x.a +FROM ( + SELECT a + FROM t + WHERE b = 1 +) x +WHERE x.a > 1; + +== should format nested subqueries == +SELECT * FROM ( + SELECT * FROM ( + SELECT a FROM t + ) AS inner_query +) AS outer_query; + +[expect] +SELECT * +FROM ( + SELECT * + FROM ( + SELECT a + FROM t + ) AS inner_query +) AS outer_query; + +== should format lateral == +SELECT * FROM t, LATERAL (SELECT * FROM u WHERE u.t_id = t.id LIMIT 1) AS l; + +[expect] +SELECT * +FROM t, LATERAL (SELECT * FROM u WHERE u.t_id = t.id LIMIT 1) AS l; + +== should format a subquery compared with an operator == +SELECT * FROM t WHERE a > (SELECT avg(a) FROM t); + +[expect] +SELECT * +FROM t +WHERE a > (SELECT avg(a) FROM t); + +== should format any and all == +SELECT * FROM t WHERE a = ANY(SELECT a FROM u) OR b > ALL (SELECT b FROM u); + +[expect] +SELECT * +FROM t +WHERE a = ANY(SELECT a FROM u) OR b > ALL (SELECT b FROM u); diff --git a/tests/specs/Select/Strings.txt b/tests/specs/Select/Strings.txt new file mode 100644 index 0000000..01e2ad4 --- /dev/null +++ b/tests/specs/Select/Strings.txt @@ -0,0 +1,80 @@ +~~ lineWidth: 40 ~~ +== should not break a list for a string spanning lines == +SELECT 'line one +line two', b FROM t; + +[expect] +SELECT 'line one +line two', b +FROM t; + +== should keep a string spanning lines against its function == +SELECT st_geomfromtext('LINESTRING(1 2, + 3 4)', 4326) AS geom FROM t; + +[expect] +SELECT st_geomfromtext('LINESTRING(1 2, + 3 4)', 4326) AS geom +FROM t; + +== should indent the first line of a string spanning lines == +SELECT first_column_name, second_column_name, 'line one +line two' AS text_value FROM t; + +[expect] +SELECT + first_column_name, + second_column_name, + 'line one +line two' AS text_value +FROM t; + +== should format values with a string spanning lines == +INSERT INTO t (a, b) VALUES (1, 'one +two'); + +[expect] +INSERT INTO t (a, b) +VALUES (1, 'one +two'); + +== should break brackets around a string spanning lines the same way every time == +SELECT a, b, f(N'a first line that is longer than the line width; + second line', 'x') AS c, d FROM t; + +[expect] +SELECT + a, + b, + f( + N'a first line that is longer than the line width; + second line', + 'x' + ) AS c, + d +FROM t; + +== should break a list around calls holding a string spanning lines the same way every time == +SELECT x, y, f('[true]', '$[*] ? (@ == true && + $x == true)', jsonb_build_object('x', x, 'y', y)) AS z FROM t; + +[expect] +SELECT + x, + y, + f( + '[true]', + '$[*] ? (@ == true && + $x == true)', + jsonb_build_object('x', x, 'y', y) + ) AS z +FROM t; + +== should keep a list on its line around a string spanning lines with an alias == +SELECT a, 'line one +line two' AS b, c FROM t; + +[expect] +SELECT a, 'line one +line two' AS b, c +FROM t; diff --git a/tests/specs/Select/With.txt b/tests/specs/Select/With.txt new file mode 100644 index 0000000..791004a --- /dev/null +++ b/tests/specs/Select/With.txt @@ -0,0 +1,105 @@ +== should format a cte == +WITH a AS (SELECT 1 AS x) SELECT x FROM a; + +[expect] +WITH a AS (SELECT 1 AS x) +SELECT x +FROM a; + +== should put each cte on its own line == +WITH a AS (SELECT 1 AS x), b AS (SELECT 2 AS y) SELECT * FROM a, b; + +[expect] +WITH a AS (SELECT 1 AS x), +b AS (SELECT 2 AS y) +SELECT * +FROM a, b; + +== should keep a cte written over several lines == +WITH a AS ( +SELECT x FROM t WHERE y = 1 +), b AS ( +SELECT x FROM a +) +SELECT * FROM b; + +[expect] +WITH a AS ( + SELECT x + FROM t + WHERE y = 1 +), +b AS ( + SELECT x + FROM a +) +SELECT * +FROM b; + +== should format a recursive cte == +WITH RECURSIVE nums (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM nums WHERE n < 10) SELECT n FROM nums; + +[expect] +WITH RECURSIVE nums (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM nums WHERE n < 10) +SELECT n +FROM nums; + +== should format column lists == +WITH a (x, y) AS (SELECT 1, 2) SELECT * FROM a; + +[expect] +WITH a (x, y) AS (SELECT 1, 2) +SELECT * +FROM a; + +== should format materialized == +WITH a AS MATERIALIZED (SELECT 1), b AS NOT MATERIALIZED (SELECT 2) SELECT * FROM a, b; + +[expect] +WITH a AS MATERIALIZED (SELECT 1), +b AS NOT MATERIALIZED (SELECT 2) +SELECT * +FROM a, b; + +== should format a cte before an insert == +WITH a AS (SELECT 1 AS x) INSERT INTO t (x) SELECT x FROM a; + +[expect] +WITH a AS (SELECT 1 AS x) +INSERT INTO t (x) +SELECT x +FROM a; + +== should format a cte before an update == +WITH a AS (SELECT id FROM t WHERE x = 1) UPDATE t SET y = 2 WHERE id IN (SELECT id FROM a); + +[expect] +WITH a AS (SELECT id FROM t WHERE x = 1) +UPDATE t +SET y = 2 +WHERE id IN (SELECT id FROM a); + +== should format a cte before a delete == +WITH a AS (SELECT id FROM t) DELETE FROM u WHERE id IN (SELECT id FROM a); + +[expect] +WITH a AS (SELECT id FROM t) +DELETE FROM u +WHERE id IN (SELECT id FROM a); + +== should format an insert of a cte == +INSERT INTO t (x) WITH a AS (SELECT 1 AS x) SELECT x FROM a; + +[expect] +INSERT INTO t (x) +WITH a AS (SELECT 1 AS x) +SELECT x +FROM a; + +== should not read a table hint as a cte == +SELECT * FROM t WITH (NOLOCK) WHERE a = 1; + +[expect] +SELECT * +FROM t WITH (NOLOCK) +WHERE a = 1; diff --git a/tests/specs/Snowflake/Snowflake_All.txt b/tests/specs/Snowflake/Snowflake_All.txt new file mode 100644 index 0000000..fe80e75 --- /dev/null +++ b/tests/specs/Snowflake/Snowflake_All.txt @@ -0,0 +1,129 @@ +~~ dialect: snowflake ~~ +== should format a procedure and its body == +CREATE OR REPLACE PROCEDURE p(x INT) RETURNS VARCHAR LANGUAGE SQL AS $$ +DECLARE result VARCHAR; +BEGIN +IF (x > 0) THEN result := 'positive'; ELSE result := 'other'; END IF; +RETURN result; +END; +$$; + +[expect] +CREATE OR REPLACE PROCEDURE p(x INT) +RETURNS VARCHAR +LANGUAGE SQL +AS $$ +DECLARE + result VARCHAR; +BEGIN + IF (x > 0) THEN + result := 'positive'; + ELSE + result := 'other'; + END IF; + RETURN result; +END; +$$; + +== should leave a javascript body alone == +CREATE PROCEDURE js() RETURNS STRING LANGUAGE JAVASCRIPT AS $$ + return "x"; +$$; + +[expect] +CREATE PROCEDURE js() +RETURNS STRING +LANGUAGE JAVASCRIPT +AS $$ + return "x"; +$$; + +== should format an anonymous block == +DECLARE c INT DEFAULT 0; +BEGIN +FOR i IN 1 TO 10 DO c := c + i; END FOR; +RETURN c; +END; + +[expect] +DECLARE + c INT DEFAULT 0; +BEGIN + FOR i IN 1 TO 10 DO + c := c + i; + END FOR; + RETURN c; +END; + +== should format execute immediate == +EXECUTE IMMEDIATE $$ SELECT 1 $$; + +[expect] +EXECUTE IMMEDIATE $$ SELECT 1 $$; + +== should format double slash comments == +SELECT 1; // a comment +// another +SELECT 2; + +[expect] +SELECT 1; // a comment +// another +SELECT 2; + +== should format semi-structured access == +SELECT v:name::string, v:address.city, v['key'], f.value FROM t, LATERAL FLATTEN(input => t.v:items) f; + +[expect] +SELECT v:name::string, v:address.city, v['key'], f.value +FROM t, LATERAL FLATTEN(input => t.v:items) f; + +== should format positional columns == +SELECT $1, $2 FROM @my_stage; + +[expect] +SELECT $1, $2 +FROM @my_stage; + +== should format copy into == +COPY INTO t FROM @stage FILE_FORMAT = (TYPE = 'CSV' SKIP_HEADER = 1) ON_ERROR = 'CONTINUE'; + +[expect] +COPY INTO t FROM @stage FILE_FORMAT = (TYPE = 'CSV' SKIP_HEADER = 1) ON_ERROR = 'CONTINUE'; + +== should format qualify == +SELECT * FROM t QUALIFY ROW_NUMBER() OVER (PARTITION BY a ORDER BY b) = 1; + +[expect] +SELECT * +FROM t +QUALIFY ROW_NUMBER() OVER (PARTITION BY a ORDER BY b) = 1; + +== should format match recognize == +SELECT * FROM t MATCH_RECOGNIZE (PARTITION BY a ORDER BY b MEASURES FIRST(b) AS start_b ONE ROW PER MATCH PATTERN (up+ down+) DEFINE up AS price > LAG(price), down AS price < LAG(price)); + +[expect] +SELECT * +FROM t MATCH_RECOGNIZE ( + PARTITION BY a + ORDER BY b + MEASURES FIRST(b) AS start_b + ONE ROW PER MATCH + PATTERN (up+ down+) + DEFINE up AS price > LAG(price), down AS price < LAG(price) +); + +== should keep the paths of stages and files == +COPY INTO t FROM @my_stage/path/file.csv; +SELECT * FROM @%t; +PUT file:///tmp/x.csv @~/staged; +LIST @my_stage/data/2024/; +REMOVE @%mytable/old; + +[expect] +COPY INTO t FROM @my_stage/path/file.csv; +SELECT * +FROM @%t; +PUT file:///tmp/x.csv @~/staged; +LIST @my_stage/data/2024/; +REMOVE @%mytable/old; diff --git a/tests/specs/SqlServer/Batches.txt b/tests/specs/SqlServer/Batches.txt new file mode 100644 index 0000000..31e0dff --- /dev/null +++ b/tests/specs/SqlServer/Batches.txt @@ -0,0 +1,143 @@ +~~ dialect: sqlserver ~~ +== should keep go on its own line == +SELECT 1 +GO +SELECT 2 +GO + +[expect] +SELECT 1 +GO +SELECT 2 +GO + +== should format go with a count == +INSERT INTO t DEFAULT VALUES +GO 10 + +[expect] +INSERT INTO t DEFAULT VALUES +GO 10 + +== should keep a comment after go == +SELECT 1 +GO -- end of batch + +[expect] +SELECT 1 +GO -- end of batch + +== should keep blank lines around go == +SELECT 1 + +GO + +SELECT 2 + +[expect] +SELECT 1 + +GO + +SELECT 2 + +== should read go after a semicolon as a batch separator == +SELECT 1; GO +SELECT 2; + +[expect] +SELECT 1; +GO +SELECT 2; + +== should format statements without semicolons == +DECLARE @a INT +SET @a = 1 +SELECT @a AS a +UPDATE t SET b = @a WHERE c = 1 +INSERT INTO t (a) VALUES (@a) +DELETE FROM t WHERE a = @a +EXEC dbo.p @a +PRINT @a + +[expect] +DECLARE @a INT +SET @a = 1 +SELECT @a AS a +UPDATE t +SET b = @a +WHERE c = 1 +INSERT INTO t (a) +VALUES (@a) +DELETE FROM t +WHERE a = @a +EXEC dbo.p @a +PRINT @a + +== should not split statements at keywords that continue them == +SELECT a FROM t UNION SELECT a FROM u +INSERT INTO t SELECT a FROM u +UPDATE t SET a = 1 FROM t JOIN u ON u.id = t.id +DROP TABLE IF EXISTS t +CREATE OR ALTER VIEW v AS SELECT 1 AS a +GRANT SELECT, UPDATE ON t TO u +ALTER TABLE t DROP COLUMN c +ALTER TABLE t ALTER COLUMN d INT +SELECT * FROM t ORDER BY a OFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY + +[expect] +SELECT a +FROM t +UNION +SELECT a +FROM u +INSERT INTO t +SELECT a +FROM u +UPDATE t +SET a = 1 +FROM t + JOIN u ON u.id = t.id +DROP TABLE IF EXISTS t +CREATE OR ALTER VIEW v AS +SELECT 1 AS a +GRANT SELECT, UPDATE ON t TO u +ALTER TABLE t DROP COLUMN c +ALTER TABLE t ALTER COLUMN d INT +SELECT * +FROM t +ORDER BY a +OFFSET 10 ROWS +FETCH NEXT 5 ROWS ONLY + +== should keep a statement after an empty statement together == +;WITH cte AS (SELECT 1 AS a) SELECT a FROM cte; + +[expect] +;WITH cte AS (SELECT 1 AS a) +SELECT a +FROM cte; + +== should format sqlcmd commands == +:setvar DatabaseName "MyDb" +USE [$(DatabaseName)]; +:r .\other.sql +GO + +[expect] +:setvar DatabaseName "MyDb" +USE [$(DatabaseName)]; +:r .\other.sql +GO + +== should split statements without semicolons after a create table == +CREATE TABLE #t (a INT) +INSERT INTO #t VALUES (1) +UPDATE #t SET a = 2 + +[expect] +CREATE TABLE #t (a INT) +INSERT INTO #t +VALUES (1) +UPDATE #t +SET a = 2 diff --git a/tests/specs/SqlServer/ControlFlow.txt b/tests/specs/SqlServer/ControlFlow.txt new file mode 100644 index 0000000..ac2081e --- /dev/null +++ b/tests/specs/SqlServer/ControlFlow.txt @@ -0,0 +1,206 @@ +~~ dialect: sqlserver ~~ +== should format if without a block == +IF @a = 1 PRINT 'one' ELSE PRINT 'other' + +[expect] +IF @a = 1 + PRINT 'one' +ELSE + PRINT 'other' + +== should format if with blocks == +IF @a = 1 BEGIN PRINT 'one'; SET @b = 1; END ELSE BEGIN PRINT 'other'; END + +[expect] +IF @a = 1 +BEGIN + PRINT 'one'; + SET @b = 1; +END +ELSE +BEGIN + PRINT 'other'; +END + +== should format else if == +IF @a = 1 PRINT 'one' ELSE IF @a = 2 PRINT 'two' ELSE PRINT 'many' + +[expect] +IF @a = 1 + PRINT 'one' +ELSE IF @a = 2 + PRINT 'two' +ELSE + PRINT 'many' + +== should format if exists == +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'x') DROP TABLE x; +IF NOT EXISTS (SELECT 1 FROM t) INSERT INTO t VALUES (1); + +[expect] +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'x') + DROP TABLE x; +IF NOT EXISTS (SELECT 1 FROM t) + INSERT INTO t + VALUES (1); + +== should format an if condition that doesn't fit == +IF @FirstVariable = 1 AND @SecondVariable = 2 AND @ThirdVariable = 3 AND EXISTS (SELECT 1 FROM dbo.SomeTable WHERE SomeColumn = @FirstVariable) PRINT 'yes'; + +[expect] +IF @FirstVariable = 1 + AND @SecondVariable = 2 + AND @ThirdVariable = 3 + AND EXISTS (SELECT 1 FROM dbo.SomeTable WHERE SomeColumn = @FirstVariable) + PRINT 'yes'; + +== should format while == +WHILE @i < 10 BEGIN SET @i = @i + 1; IF @i = 5 CONTINUE; IF @i = 8 BREAK; END + +[expect] +WHILE @i < 10 +BEGIN + SET @i = @i + 1; + IF @i = 5 + CONTINUE; + IF @i = 8 + BREAK; +END + +== should format while without a block == +WHILE @i < 10 SET @i = @i + 1; + +[expect] +WHILE @i < 10 + SET @i = @i + 1; + +== should format try and catch == +BEGIN TRY SELECT 1 / 0; END TRY BEGIN CATCH SELECT ERROR_MESSAGE() AS msg; THROW; END CATCH + +[expect] +BEGIN TRY + SELECT 1 / 0; +END TRY +BEGIN CATCH + SELECT ERROR_MESSAGE() AS msg; + THROW; +END CATCH + +== should format nested blocks == +IF @a = 1 +BEGIN +WHILE @b < 10 +BEGIN +IF @b = 5 +BEGIN +BREAK; +END +SET @b += 1; +END +END + +[expect] +IF @a = 1 +BEGIN + WHILE @b < 10 + BEGIN + IF @b = 5 + BEGIN + BREAK; + END + SET @b += 1; + END +END + +== should format goto and labels == +IF @a = 1 GOTO done; +PRINT 'not done'; +done: +PRINT 'done'; + +[expect] +IF @a = 1 + GOTO done; +PRINT 'not done'; +done: +PRINT 'done'; + +== should format waitfor and raiserror == +WAITFOR DELAY '00:00:05'; +RAISERROR('failed: %s', 16, 1, @msg) WITH NOWAIT; + +[expect] +WAITFOR DELAY '00:00:05'; +RAISERROR('failed: %s', 16, 1, @msg) WITH NOWAIT; + +== should format transactions == +BEGIN TRAN; +BEGIN TRANSACTION t1; +COMMIT TRAN; +ROLLBACK TRANSACTION t1; +SAVE TRANSACTION s; + +[expect] +BEGIN TRAN; +BEGIN TRANSACTION t1; +COMMIT TRAN; +ROLLBACK TRANSACTION t1; +SAVE TRANSACTION s; + +== should format cursors == +DECLARE c CURSOR LOCAL FAST_FORWARD FOR SELECT Id FROM dbo.Users; +OPEN c; +FETCH NEXT FROM c INTO @id; +WHILE @@FETCH_STATUS = 0 +BEGIN +PRINT @id; +FETCH NEXT FROM c INTO @id; +END +CLOSE c; +DEALLOCATE c; + +[expect] +DECLARE c CURSOR LOCAL FAST_FORWARD FOR SELECT Id FROM dbo.Users; +OPEN c; +FETCH NEXT FROM c INTO @id; +WHILE @@FETCH_STATUS = 0 +BEGIN + PRINT @id; + FETCH NEXT FROM c INTO @id; +END +CLOSE c; +DEALLOCATE c; + +== should end an update at the next set == +IF @rc = 0 UPDATE t SET b = 1 +SET @done = 1 +UPDATE t SET a = 1 WHERE id = 2 SET @rc = @@ROWCOUNT +MERGE t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET a = 1 +SET @x = 1 + +[expect] +IF @rc = 0 + UPDATE t + SET b = 1 +SET @done = 1 +UPDATE t +SET a = 1 +WHERE id = 2 +SET @rc = @@ROWCOUNT +MERGE t +USING s +ON t.id = s.id +WHEN MATCHED THEN + UPDATE SET a = 1 +SET @x = 1 + +== should end a return without a value at the end of its line == +IF @x IS NULL + RETURN +SELECT * FROM t + +[expect] +IF @x IS NULL + RETURN +SELECT * +FROM t diff --git a/tests/specs/SqlServer/Procedures.txt b/tests/specs/SqlServer/Procedures.txt new file mode 100644 index 0000000..6c3f61f --- /dev/null +++ b/tests/specs/SqlServer/Procedures.txt @@ -0,0 +1,182 @@ +~~ dialect: sqlserver ~~ +== should format a procedure == +CREATE PROCEDURE dbo.GetUser @Id INT AS BEGIN SET NOCOUNT ON; SELECT * FROM dbo.Users WHERE Id = @Id; END +GO + +[expect] +CREATE PROCEDURE dbo.GetUser @Id INT +AS +BEGIN + SET NOCOUNT ON; + SELECT * + FROM dbo.Users + WHERE Id = @Id; +END +GO + +== should break parameters that don't fit == +CREATE OR ALTER PROCEDURE dbo.GetOrders @CustomerId INT, @Since DATETIME2 = NULL, @IncludeCancelled BIT = 0, @Total MONEY OUTPUT AS +BEGIN +SELECT @Total = SUM(Total) FROM dbo.Orders WHERE CustomerId = @CustomerId; +END +GO + +[expect] +CREATE OR ALTER PROCEDURE dbo.GetOrders + @CustomerId INT, + @Since DATETIME2 = NULL, + @IncludeCancelled BIT = 0, + @Total MONEY OUTPUT +AS +BEGIN + SELECT @Total = SUM(Total) + FROM dbo.Orders + WHERE CustomerId = @CustomerId; +END +GO + +== should format parameters in brackets == +CREATE PROCEDURE dbo.p (@a INT, @b INT) AS SELECT @a + @b; +GO + +[expect] +CREATE PROCEDURE dbo.p (@a INT, @b INT) +AS +SELECT @a + @b; +GO + +== should format a procedure without a block == +CREATE PROCEDURE dbo.p +AS +SET NOCOUNT ON +SELECT 1 +SELECT 2 +GO + +[expect] +CREATE PROCEDURE dbo.p +AS +SET NOCOUNT ON +SELECT 1 +SELECT 2 +GO + +== should format procedure options == +CREATE PROCEDURE dbo.p @a INT WITH RECOMPILE, EXECUTE AS OWNER AS BEGIN SELECT @a; END +GO + +[expect] +CREATE PROCEDURE dbo.p @a INT +WITH RECOMPILE, EXECUTE AS OWNER +AS +BEGIN + SELECT @a; +END +GO + +== should format a scalar function == +CREATE FUNCTION dbo.AddOne(@a INT) RETURNS INT AS BEGIN RETURN @a + 1; END +GO + +[expect] +CREATE FUNCTION dbo.AddOne(@a INT) +RETURNS INT +AS +BEGIN + RETURN @a + 1; +END +GO + +== should format an inline table valued function == +CREATE FUNCTION dbo.ActiveUsers() RETURNS TABLE AS RETURN (SELECT Id, Name FROM dbo.Users WHERE Active = 1); +GO + +[expect] +CREATE FUNCTION dbo.ActiveUsers() +RETURNS TABLE +AS RETURN (SELECT Id, Name FROM dbo.Users WHERE Active = 1); +GO + +== should format a multi-statement table valued function == +CREATE FUNCTION dbo.Numbers(@n INT) RETURNS @t TABLE (n INT) AS +BEGIN +DECLARE @i INT = 1; +WHILE @i <= @n BEGIN INSERT INTO @t VALUES (@i); SET @i += 1; END +RETURN; +END +GO + +[expect] +CREATE FUNCTION dbo.Numbers(@n INT) +RETURNS @t TABLE (n INT) +AS +BEGIN + DECLARE @i INT = 1; + WHILE @i <= @n + BEGIN + INSERT INTO @t + VALUES (@i); + SET @i += 1; + END + RETURN; +END +GO + +== should format a trigger == +CREATE TRIGGER dbo.trg_Users ON dbo.Users AFTER INSERT, UPDATE AS +BEGIN +SET NOCOUNT ON; +IF UPDATE(Name) UPDATE u SET Modified = GETDATE() FROM dbo.Users u INNER JOIN inserted i ON i.Id = u.Id; +END +GO + +[expect] +CREATE TRIGGER dbo.trg_Users ON dbo.Users AFTER INSERT, UPDATE +AS +BEGIN + SET NOCOUNT ON; + IF UPDATE(Name) + UPDATE u + SET Modified = GETDATE() + FROM dbo.Users u + INNER JOIN inserted i ON i.Id = u.Id; +END +GO + +== should format exec == +EXEC dbo.GetUser @Id = 1; +EXEC dbo.GetOrders 1, '2020-01-01'; +EXECUTE @result = dbo.p @a = 1, @b = @x OUTPUT; +EXEC (@sql); +EXEC sp_executesql @sql, N'@id INT', @id = 1; + +[expect] +EXEC dbo.GetUser @Id = 1; +EXEC dbo.GetOrders 1, '2020-01-01'; +EXECUTE @result = dbo.p @a = 1, @b = @x OUTPUT; +EXEC (@sql); +EXEC sp_executesql @sql, N'@id INT', @id = 1; + +== should break exec arguments that don't fit == +EXEC dbo.SomeLongProcedureName @FirstParameter = 'first value', @SecondParameter = 'second value', @ThirdParameter = 3, @Fourth = 4; + +[expect] +EXEC dbo.SomeLongProcedureName + @FirstParameter = 'first value', + @SecondParameter = 'second value', + @ThirdParameter = 3, + @Fourth = 4; + +== should not read an option as a common table expression == +CREATE VIEW v WITH SCHEMABINDING AS SELECT a FROM dbo.t +GO +CREATE PROCEDURE p WITH EXECUTE AS OWNER AS SELECT 1 + +[expect] +CREATE VIEW v WITH SCHEMABINDING AS +SELECT a +FROM dbo.t +GO +CREATE PROCEDURE p WITH EXECUTE AS OWNER +AS +SELECT 1 diff --git a/tests/specs/SqlServer/Syntax.txt b/tests/specs/SqlServer/Syntax.txt new file mode 100644 index 0000000..111b36b --- /dev/null +++ b/tests/specs/SqlServer/Syntax.txt @@ -0,0 +1,130 @@ +~~ dialect: sqlserver ~~ +== should format bracketed identifiers == +SELECT [Id], [First Name], [Weird]]Name] FROM [dbo].[Users] AS [u]; + +[expect] +SELECT [Id], [First Name], [Weird]]Name] +FROM [dbo].[Users] AS [u]; + +== should format temporary tables == +CREATE TABLE #t (Id INT); +SELECT * INTO ##global FROM #t; +DROP TABLE #t; + +[expect] +CREATE TABLE #t (Id INT); +SELECT * +INTO ##global +FROM #t; +DROP TABLE #t; + +== should format variables == +DECLARE @a INT = 1, @b NVARCHAR(10) = N'x', @c TABLE (Id INT); +SET @a += 1; +SELECT @a = MAX(Id) FROM t; +SELECT @@ROWCOUNT, @@IDENTITY; + +[expect] +DECLARE @a INT = 1, @b NVARCHAR(10) = N'x', @c TABLE (Id INT); +SET @a += 1; +SELECT @a = MAX(Id) +FROM t; +SELECT @@ROWCOUNT, @@IDENTITY; + +== should format a long declare == +DECLARE @FirstVariable INT = 1, @SecondVariable NVARCHAR(100) = N'second', @ThirdVariable DATETIME2 = SYSDATETIME(), @Fourth INT; + +[expect] +DECLARE + @FirstVariable INT = 1, + @SecondVariable NVARCHAR(100) = N'second', + @ThirdVariable DATETIME2 = SYSDATETIME(), + @Fourth INT; + +== should format top and output == +UPDATE TOP (10) t SET a = 1 OUTPUT inserted.Id, deleted.a WHERE b = 2; + +[expect] +UPDATE TOP (10) t +SET a = 1 +OUTPUT inserted.Id, deleted.a +WHERE b = 2; + +== should format table hints == +SELECT * FROM t WITH (NOLOCK) INNER JOIN u WITH (INDEX(ix), NOLOCK) ON u.Id = t.Id OPTION (RECOMPILE, MAXDOP 1); + +[expect] +SELECT * +FROM t WITH (NOLOCK) + INNER JOIN u WITH (INDEX(ix), NOLOCK) ON u.Id = t.Id +OPTION (RECOMPILE, MAXDOP 1); + +== should format apply == +SELECT * FROM t CROSS APPLY OPENJSON(t.Data) WITH (Id INT '$.id') AS j; + +[expect] +SELECT * +FROM t + CROSS APPLY OPENJSON(t.Data) WITH (Id INT '$.id') AS j; + +== should format xml methods == +SELECT x.value('(/a/b)[1]', 'INT') FROM @xml.nodes('/a') AS t(x); + +[expect] +SELECT x.value('(/a/b)[1]', 'INT') +FROM @xml.nodes('/a') AS t(x); + +== should format money and unicode literals == +SELECT $1.50, N'unicode', 0x0F; + +[expect] +SELECT $1.50, N'unicode', 0x0F; + +== should format the merge output action == +MERGE t USING s ON t.Id = s.Id WHEN MATCHED THEN UPDATE SET t.a = s.a OUTPUT $action; + +[expect] +MERGE t +USING s +ON t.Id = s.Id +WHEN MATCHED THEN + UPDATE SET t.a = s.a +OUTPUT $action; + +== should format pivot == +SELECT * FROM (SELECT a, b, c FROM t) AS src PIVOT (SUM(c) FOR b IN ([x], [y])) AS p; + +[expect] +SELECT * +FROM (SELECT a, b, c FROM t) AS src PIVOT (SUM(c) FOR b IN ([x], [y])) AS p; + +== should format for xml == +SELECT a FROM t FOR XML PATH('row'), ROOT('rows'); + +[expect] +SELECT a +FROM t +FOR XML PATH('row'), ROOT('rows'); + +== should format temporal queries == +SELECT * FROM t FOR SYSTEM_TIME AS OF '2020-01-01' WHERE a = 1; + +[expect] +SELECT * +FROM t FOR SYSTEM_TIME AS OF '2020-01-01' +WHERE a = 1; + +== should end an operator before a sign == +SELECT * FROM t WHERE x!=-1 AND y%-2=0 AND z|-1=0; + +[expect] +SELECT * +FROM t +WHERE x != -1 AND y % -2 = 0 AND z | -1 = 0; + +== should keep a column named like a clause in a list == +SELECT id, offset, filter, identity FROM t; + +[expect] +SELECT id, offset, filter, identity +FROM t; diff --git a/tests/specs/Sqlite/Sqlite_All.txt b/tests/specs/Sqlite/Sqlite_All.txt new file mode 100644 index 0000000..c1ee9d9 --- /dev/null +++ b/tests/specs/Sqlite/Sqlite_All.txt @@ -0,0 +1,84 @@ +~~ dialect: sqlite ~~ +== should format a trigger == +CREATE TRIGGER update_ts AFTER UPDATE ON t BEGIN UPDATE t SET ts = CURRENT_TIMESTAMP WHERE id = NEW.id; END; + +[expect] +CREATE TRIGGER update_ts AFTER UPDATE ON t +BEGIN + UPDATE t + SET ts = CURRENT_TIMESTAMP + WHERE id = NEW.id; +END; + +== should format a trigger with several statements == +CREATE TRIGGER IF NOT EXISTS log_delete BEFORE DELETE ON t FOR EACH ROW WHEN OLD.protected = 0 +BEGIN +INSERT INTO log (id) VALUES (OLD.id); +DELETE FROM children WHERE parent_id = OLD.id; +END; + +[expect] +CREATE TRIGGER IF NOT EXISTS log_delete BEFORE DELETE ON t FOR EACH ROW WHEN OLD.protected = 0 +BEGIN + INSERT INTO log (id) + VALUES (OLD.id); + DELETE FROM children + WHERE parent_id = OLD.id; +END; + +== should format identifiers == +SELECT [a], `b`, "c" FROM [t]; + +[expect] +SELECT [a], `b`, "c" +FROM [t]; + +== should format parameters == +SELECT * FROM t WHERE a = ? AND b = ?1 AND c = :c AND d = @d AND e = $e; + +[expect] +SELECT * +FROM t +WHERE a = ? AND b = ?1 AND c = :c AND d = @d AND e = $e; + +== should format upsert == +INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT (a) DO UPDATE SET b = excluded.b; + +[expect] +INSERT INTO t (a, b) +VALUES (1, 2) +ON CONFLICT (a) DO UPDATE +SET b = excluded.b; + +== should format insert or ignore == +INSERT OR IGNORE INTO t (a) VALUES (1); + +[expect] +INSERT OR IGNORE INTO t (a) +VALUES (1); + +== should format a create table == +CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL COLLATE NOCASE, data BLOB) WITHOUT ROWID; + +[expect] +CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL COLLATE NOCASE, data BLOB) WITHOUT ROWID; + +== should format pragmas == +PRAGMA foreign_keys = ON; +PRAGMA table_info(t); + +[expect] +PRAGMA foreign_keys = ON; +PRAGMA table_info(t); + +== should format attach == +ATTACH DATABASE 'other.db' AS other; + +[expect] +ATTACH DATABASE 'other.db' AS other; + +== should format a comment that runs to the end of the file == +SELECT 1; /* unterminated + +[expect] +SELECT 1; /* unterminated diff --git a/tests/test.rs b/tests/test.rs index c092d98..3d97e44 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,45 +1,322 @@ -extern crate dprint_development; -extern crate dprint_plugin_sql; - +use std::path::Path; use std::path::PathBuf; -use std::sync::Arc; -use dprint_core::configuration::*; -use dprint_development::*; -use dprint_plugin_sql::configuration::{resolve_config, ConfigurationBuilder}; +use dprint_plugin_sql::configuration::ConfigurationBuilder; +use dprint_plugin_sql::configuration::Dialect; +use dprint_plugin_sql::configuration::UppercaseKind; use dprint_plugin_sql::*; #[test] -fn test_specs() { - let global_config = GlobalConfiguration::default(); +fn should_handle_windows_newlines() { + let config = ConfigurationBuilder::new().build(); + let file_text = format_text(&PathBuf::from("file.sql"), "SELECT * FROM dbo.Test\r\n", &config).unwrap(); + + assert_eq!(file_text.unwrap(), "SELECT *\nFROM dbo.Test\n"); +} + +#[test] +fn should_keep_windows_newlines_in_auto_mode() { + let config = ConfigurationBuilder::new() + .new_line_kind(dprint_core::configuration::NewLineKind::Auto) + .build(); + let file_text = format_text(&PathBuf::from("file.sql"), "SELECT * FROM dbo.Test\r\n", &config).unwrap(); - run_specs( - &PathBuf::from("./tests/specs"), - &ParseSpecOptions { - default_file_name: "file.sql", - }, - &RunSpecsOptions { - fix_failures: false, - format_twice: true, - }, - { - let global_config = global_config.clone(); - Arc::new(move |file_path, file_text, spec_config| { - let spec_config: ConfigKeyMap = serde_json::from_value(spec_config.clone().into()).unwrap(); - let config_result = resolve_config(spec_config, &global_config); - ensure_no_diagnostics(&config_result.diagnostics); + assert_eq!(file_text.unwrap(), "SELECT *\r\nFROM dbo.Test\r\n"); +} - Ok(format_text(file_path, &file_text, &config_result.config)) - }) - }, - Arc::new(move |_file_path, _file_text, _spec_config| panic!("Plugin does not support dprint-core tracing.")), - ) +#[test] +fn should_format_empty_file() { + let config = ConfigurationBuilder::new().build(); + assert_eq!(format_text(&PathBuf::from("file.sql"), "", &config).unwrap(), None); + assert_eq!( + format_text(&PathBuf::from("file.sql"), "\n\n", &config) + .unwrap() + .as_deref(), + Some("") + ); } #[test] -fn should_handle_windows_newlines() { +fn should_leave_file_with_ignore_file_comment() { let config = ConfigurationBuilder::new().build(); - let file_text = format_text(&PathBuf::from("file.sql"), "SELECT * FROM dbo.Test\r\n", &config).unwrap(); + let text = "-- dprint-ignore-file\nSELECT * FROM t;\n"; + assert_eq!(format_text(&PathBuf::from("file.sql"), text, &config).unwrap(), None); + let range = format_text_range(&PathBuf::from("file.sql"), text, 25..26, &config).unwrap(); + assert_eq!(range, None); +} + +/// Nesting deeply enough to run out of stack aborts the process rather than raising an error, so +/// the parser refuses well before that. +#[test] +fn should_reject_deeply_nested_brackets() { + let config = ConfigurationBuilder::new().build(); + let text = format!("SELECT {}1{}", "(".repeat(500), ")".repeat(500)); + let error = format_text(&PathBuf::from("file.sql"), &text, &config) + .unwrap_err() + .to_string(); + assert!(error.contains("nested too deeply"), "got {error}"); + // a depth a real query might plausibly reach is still accepted + let nested = format!("SELECT {}1{}", "(".repeat(32), ")".repeat(32)); + assert!(format_text(&PathBuf::from("file.sql"), &nested, &config).is_ok()); +} + +#[test] +fn should_reject_deeply_nested_blocks() { + let config = ConfigurationBuilder::new().dialect(Dialect::PostgreSql).build(); + let text = format!("DO $$ {} NULL; {} $$", "BEGIN ".repeat(300), "END; ".repeat(300)); + // the body of the `DO` is left as it is when it can't be formatted + assert!(format_text(&PathBuf::from("file.sql"), &text, &config).is_ok()); + let text = format!("{} NULL; {}", "BEGIN ".repeat(300), "END; ".repeat(300)); + let error = format_text(&PathBuf::from("file.sql"), &text, &config) + .unwrap_err() + .to_string(); + assert!(error.contains("nested too deeply"), "got {error}"); +} + +#[test] +fn should_quickly_format_many_blocks_without_an_end() { + // outside of PostgreSQL, `end$$` is one word, so none of these blocks are ended, which once took + // time exponential in their number to find out + let texts = [ + "do $$ declare a int[]; +begin a := 1; raise notice 'a = %', a; end$$; +", + "create function f(in i int, out j int) as $$ +begin + j := i; + return; +end$$ language plpgsql; +select * from f(42); +", + "create function f(a text) returns int as $$ +declare x int; +begin + if found then return x; end if; + return 0; +end$$ language plpgsql stable; +", + ]; + for dialect in [ + Dialect::Generic, + Dialect::PostgreSql, + Dialect::MySql, + Dialect::SqlServer, + Dialect::Sqlite, + Dialect::Oracle, + Dialect::BigQuery, + Dialect::Snowflake, + ] { + let config = ConfigurationBuilder::new().dialect(dialect).build(); + for text in texts { + let text = text.repeat(60); + let start = std::time::Instant::now(); + let _ = format_text(&PathBuf::from("file.sql"), &text, &config); + assert!( + start.elapsed().as_secs() < 5, + "took {:?} in {dialect:?} for: +{text}", + start.elapsed() + ); + } + } +} + +#[test] +fn should_quickly_format_deeply_nested_code() { + let config = ConfigurationBuilder::new().build(); + let texts = [ + // each `CASE` that doesn't end was once parsed again for every one around it + format!( + "SELECT {}1{} FROM t;\n", + "CASE WHEN a THEN (".repeat(40), + ")".repeat(40) + ), + // and the printer's work to fit nested values on lines grew exponentially with their depth + format!( + "SELECT {}1{} FROM t;\n", + "CASE WHEN a THEN ".repeat(40), + " END".repeat(40) + ), + format!( + "SELECT {}(1){};\n", + "(SELECT a FROM t WHERE b IN ".repeat(40), + ")".repeat(40) + ), + ]; + for text in texts { + let start = std::time::Instant::now(); + let _ = format_text(&PathBuf::from("file.sql"), &text, &config); + assert!(start.elapsed().as_secs() < 5, "took {:?} for:\n{text}", start.elapsed()); + } +} + +#[test] +fn should_report_parse_errors() { + let cases = [ + (Dialect::Generic, "SELECT 'unterminated\n", "unterminated string"), + ( + Dialect::Generic, + "SELECT \"unterminated\n", + "unterminated quoted identifier", + ), + ( + Dialect::SqlServer, + "SELECT [unterminated\n", + "unterminated quoted identifier", + ), + ( + Dialect::Generic, + "SELECT 1 /* unterminated\n", + "unterminated block comment", + ), + ( + Dialect::PostgreSql, + "SELECT $$unterminated\n", + "unterminated dollar-quoted string", + ), + ( + Dialect::Generic, + "SELECT {{ unterminated\n", + "unterminated template tag", + ), + ( + Dialect::Oracle, + "SELECT q'[unterminated' FROM dual\n", + "unterminated string", + ), + (Dialect::Generic, "SELECT (1\n", "unclosed '('"), + (Dialect::Generic, "SELECT arr[1\n", "unclosed '['"), + (Dialect::Generic, "SELECT (1]\n", "expected ')' to close the '('"), + (Dialect::Generic, "SELECT 1)\n", "unexpected closing bracket"), + ]; + for (dialect, input, expected) in cases { + let config = ConfigurationBuilder::new().dialect(dialect).build(); + let error = format_text(&PathBuf::from("file.sql"), input, &config) + .unwrap_err() + .to_string(); + assert!( + error.contains(expected), + "for {input:?}\n expected message containing {expected:?}\n got {error}" + ); + } +} + +#[test] +fn should_suggest_dialect_for_backslash_escapes() { + let config = ConfigurationBuilder::new().build(); + let error = format_text(&PathBuf::from("file.sql"), "SELECT 'it\\'s'\n", &config) + .unwrap_err() + .to_string(); + assert!(error.contains("set the \"dialect\" option"), "got {error}"); + // which formats in a dialect where a backslash escapes a quote + let config = ConfigurationBuilder::new().dialect(Dialect::MySql).build(); + assert!(format_text(&PathBuf::from("file.sql"), "SELECT 'it\\'s'\n", &config).is_ok()); +} + +#[test] +fn should_point_at_error_position() { + let config = ConfigurationBuilder::new().build(); + let error = format_text(&PathBuf::from("file.sql"), "SELECT 1;\nSELECT 'oops\n", &config) + .unwrap_err() + .to_string(); + assert!(error.contains("Line 2, column 8"), "got {error}"); +} + +/// A comment may legitimately end in whitespace that isn't a space or a tab, and `trim_end` would +/// take it. +#[test] +fn should_keep_unicode_whitespace_in_a_comment() { + let config = ConfigurationBuilder::new().build(); + let text = format_text(&PathBuf::from("file.sql"), "SELECT 1; -- hi\u{a0}\n", &config).unwrap(); + assert_eq!(text, None, "the comment should have been left exactly as written"); +} + +#[test] +fn should_keep_multi_line_strings_as_they_are() { + let config = ConfigurationBuilder::new().build(); + let text = "SELECT 'line one\n line two\n line three' AS s, \"col\nname\" FROM t;\n"; + let formatted = format_text(&PathBuf::from("file.sql"), text, &config).unwrap().unwrap(); + assert!(formatted.contains("'line one\n line two\n line three'")); + assert!(formatted.contains("\"col\nname\"")); +} + +#[test] +fn should_only_change_casing_of_keywords() { + let config = ConfigurationBuilder::new().casing(UppercaseKind::Upper).build(); + let text = "select name, \"select\", t.from, x.order, count(*) from t where name like 'select%';\n"; + let formatted = format_text(&PathBuf::from("file.sql"), text, &config).unwrap().unwrap(); + assert_eq!( + formatted, + "SELECT name, \"select\", t.from, x.order, count(*)\nFROM t\nWHERE name LIKE 'select%';\n" + ); +} + +/// Formatting only ever changes the whitespace between tokens, which is checked after formatting +/// anything. This runs that check over every spec's input in every dialect, which catches the +/// cases where a dialect a spec isn't written for reads its text differently. +#[test] +fn should_only_change_whitespace_of_spec_inputs_in_every_dialect() { + let dialects = [ + Dialect::Generic, + Dialect::PostgreSql, + Dialect::MySql, + Dialect::SqlServer, + Dialect::Sqlite, + Dialect::Oracle, + Dialect::BigQuery, + Dialect::Snowflake, + ]; + let mut checked = 0; + for input in spec_inputs(Path::new("tests/specs")) { + for dialect in dialects { + for line_width in [40, 120] { + let config = ConfigurationBuilder::new() + .dialect(dialect) + .line_width(line_width) + .build(); + match format_text(Path::new("file.sql"), &input, &config) { + Ok(Some(formatted)) => { + let again = format_text(Path::new("file.sql"), &formatted, &config).unwrap(); + assert_eq!( + again, None, + "formatting again changed it in {dialect:?} at width {line_width}:\n{input}" + ); + checked += 1; + } + Ok(None) => checked += 1, + // the text might not be valid in this dialect (ex. a MySQL string escaped with a backslash) + Err(FormatError::Parse(_)) => {} + Err(err) => panic!("{err} in {dialect:?} at width {line_width} for:\n{input}"), + } + } + } + } + assert!(checked > 1000, "only checked {checked}"); +} - assert_eq!(file_text, "SELECT\n *\nFROM\n dbo.Test\n"); +/// The input text of each spec in the spec files beneath `dir`. +fn spec_inputs(dir: &Path) -> Vec { + let mut inputs = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + inputs.extend(spec_inputs(&path)); + continue; + } + let text = std::fs::read_to_string(&path).unwrap().replace("\r\n", "\n"); + let mut current: Option> = None; + for line in text.lines() { + if line.starts_with("== ") && line.ends_with(" ==") { + current = Some(Vec::new()); + } else if line == "[expect]" { + if let Some(lines) = current.take() { + let input = lines.join("\n").replace("[|", "").replace("|]", ""); + inputs.push(input.trim_end().to_string() + "\n"); + } + } else if let Some(lines) = current.as_mut() { + lines.push(line); + } + } + } + inputs }