From 9cd9a03469c7a3a7750fe3d445dff14445e0f83c Mon Sep 17 00:00:00 2001 From: konojunya Date: Thu, 3 Sep 2026 13:29:52 +0900 Subject: [PATCH] Define canonical formatter contract and fixtures --- .gitattributes | 1 + .github/workflows/ci.yml | 11 +- FORMATTER.md | 138 ++++++++++++++++++ INTERCHANGE.md | 4 +- README.md | 4 +- SPECIFICATION.md | 12 +- conformance/README.md | 8 +- .../comments-and-layout/expected.ir.json | 85 +++++++++++ .../comments-and-layout/expected.stack | 33 +++++ .../formatter/comments-and-layout/input.stack | 19 +++ scripts/validate-formatter-fixtures.py | 51 +++++++ 11 files changed, 352 insertions(+), 14 deletions(-) create mode 100644 FORMATTER.md create mode 100644 conformance/formatter/comments-and-layout/expected.ir.json create mode 100644 conformance/formatter/comments-and-layout/expected.stack create mode 100644 conformance/formatter/comments-and-layout/input.stack create mode 100644 scripts/validate-formatter-fixtures.py diff --git a/.gitattributes b/.gitattributes index 00657cd..5f1c7cc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ conformance/invalid/byte-order-mark/source.stack binary conformance/invalid/invalid-utf8/source.stack binary +conformance/formatter/comments-and-layout/input.stack binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96721a9..e81f78e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,15 +40,24 @@ jobs: test -f "$case/source.stack" test -f "$case/expected.diagnostics.json" done + for case in conformance/formatter/*; do + test -d "$case" + test -f "$case/input.stack" + test -f "$case/expected.stack" + test -f "$case/expected.ir.json" + done - name: Validate JSON Schemas run: check-jsonschema --check-metaschema schemas/*.json - name: Validate normalized IR fixtures - run: check-jsonschema --schemafile schemas/normalized-ir.schema.json conformance/valid/*/expected.ir.json + run: check-jsonschema --schemafile schemas/normalized-ir.schema.json conformance/valid/*/expected.ir.json conformance/formatter/*/expected.ir.json - name: Validate diagnostic fixtures run: find conformance -name expected.diagnostics.json -print0 | xargs -0 check-jsonschema --schemafile schemas/diagnostic-expectations.schema.json - name: Validate compiler diagnostic coverage run: python scripts/validate-compiler-diagnostics.py + + - name: Validate formatter fixtures + run: python scripts/validate-formatter-fixtures.py diff --git a/FORMATTER.md b/FORMATTER.md new file mode 100644 index 0000000..219ab14 --- /dev/null +++ b/FORMATTER.md @@ -0,0 +1,138 @@ +# Stack Canonical Formatter Specification + +## 1. Status and Scope + +This document is a normative part of the draft Stack 1.0 specification. It defines the one canonical byte representation of a compiler-valid `.stack` document. + +A canonical formatter accepts UTF-8 Stack source without a byte order mark. It MUST reject a document that produces a compiler-stage error and MUST NOT present partial rewritten source as a successful result. Warnings do not prevent formatting. + +Formatting MUST preserve the document's normalized meaning and every line comment. Formatting never changes the declared language version. + +## 2. Canonical Output + +Canonical output MUST: + +- be UTF-8 without a byte order mark; +- use LF (`U+000A`) for every line ending, regardless of the input line endings; +- use ASCII spaces, never tabs, for formatting whitespace; +- contain no formatting whitespace at the end of a line; +- end with exactly one LF. + +Whitespace inside a line comment is comment text rather than formatting whitespace and is preserved as described in [Section 6](#6-comments). + +## 3. Indentation and Lines + +Each block increases indentation by two ASCII spaces. The version directive and diagram declaration have zero indentation. A closing brace has the same indentation as the declaration that opened its block. + +Absent a comment between two tokens that would otherwise share a line, the formatter MUST put each of the following on one line: + +- the version directive; +- a diagram, group, node, or edge declaration header, including its opening brace when present; +- a theme statement; +- a node or edge property; +- a layout statement. + +Opening braces are preceded by one ASCII space and remain on the declaration or `layout` line. Closing braces are on their own line. There is no blank line immediately after an opening brace or immediately before its closing brace. + +The canonical token spacing is: + +```stack +stack 1.0 + +diagram "Title" { + theme dark + + node client "Client" + + group services "Services" { + node api "API" { + kind service + icon "service" + detail "Public API" + } + } + + edge client -> api "HTTPS" { + kind request + } + + layout { + direction right + rank same [client, services] + order [client, services] + } +} +``` + +The formatter MUST use one ASCII space at the positions shown above, no space around the version dot, and no space just inside brackets. + +## 4. Blank Lines + +The version directive and diagram declaration are separated by exactly one empty line. + +Adjacent members of a diagram or group body are separated by exactly one empty line. A member is a node, group, edge, theme, or layout construct. Properties in node and edge blocks and statements in layout blocks have no empty lines between them. + +Leading comments belong to the member or statement that follows them. A separator is placed before the first leading comment, not between that comment and its member. A trailing comment belongs to the preceding line, so any separator follows the comment. + +No other empty lines are emitted. + +## 5. Order, Lists, and Strings + +### 5.1 Order + +The formatter MUST preserve the authored order of: + +- diagram and group members, including the position of theme and layout constructs; +- node and edge properties; +- layout statements; +- identifiers in `rank same` and `order` lists. + +It MUST NOT group or sort declarations or properties. This preserves declaration-order data in normalized IR and keeps comments attached to the same token boundaries. + +### 5.2 Identifier Lists + +An identifier list is emitted on one line as an opening bracket, the identifiers in authored order separated by a comma and one ASCII space, and a closing bracket. No trailing comma is emitted. + +```stack +rank same [frontend, backend] +order [frontend, backend] +``` + +### 5.3 Strings + +The formatter decodes each valid source string and emits its Unicode scalar values without Unicode normalization. A double quote is emitted as `\"`, a backslash is emitted as `\\`, and every other permitted scalar value is emitted directly as UTF-8. Canonical output therefore does not use `\uXXXX` escapes. + +For example, `"API \u56F3 \uD83D\uDE80"` becomes `"API 図 🚀"`, while decoded quote and backslash characters remain escaped. + +## 6. Comments + +The bytes from `//` through the byte before its line ending form the comment lexeme. The formatter MUST preserve that lexeme exactly and MUST preserve comment order. It also MUST preserve the comment's gap between the same preceding and following non-comment tokens; string canonicalization does not change token identity for this rule. + +A comment is **trailing** when a non-comment token precedes it on the same input line. A trailing comment is emitted immediately after its preceding token, preceded by one ASCII space. The line ends immediately after the comment lexeme. If another token in the same construct follows the comment, formatting resumes on the next line using the continuation indentation defined below. + +Every other comment is an **own-line** comment. Consecutive own-line comments at one token gap remain consecutive, use the indentation of the following member, statement, or property, and are emitted immediately before it. If the next token closes a block, the comments use the indentation of that block's members. If the next token is end-of-file, they use zero indentation. + +A comment may occur at a token gap inside a construct that canonical formatting would otherwise place on one line. The comment remains at that token gap and forces a line break. The comment and the remaining tokens use one additional indentation level relative to the construct's first line when they do not already have a greater block indentation. This comment-forced continuation is the only exception to the one-line rules in [Section 3](#3-indentation-and-lines). + +Comments before the version directive form its leading comment block. Comments after the diagram's closing brace form a final own-line comment block separated from the diagram by one empty line. + +## 7. Conformance Fixtures + +Canonical formatter cases live in `conformance/formatter/`. Each case directory contains exactly: + +```text +conformance/formatter//input.stack +conformance/formatter//expected.stack +conformance/formatter//expected.ir.json +``` + +For every case, a conforming formatter runner MUST: + +1. format `input.stack` and compare the output bytes exactly with `expected.stack`; +2. format `expected.stack` and require byte-identical `expected.stack` output, proving idempotence; +3. compile both `input.stack` and `expected.stack` with compiler stages enabled and catalog, layout, and renderer stages disabled; +4. require both compilations to produce no compiler-stage error; +5. compare both normalized IR documents semantically with `expected.ir.json`, proving semantic preservation; +6. record the specification release or commit revision used for the run. + +JSON object-member order and JSON whitespace are not significant. Array order is significant. A warning does not invalidate a formatter case, but portable diagnostic code, severity, and range are not compared across formatting because canonical whitespace changes source ranges. diff --git a/INTERCHANGE.md b/INTERCHANGE.md index fa61319..e7de5d2 100644 --- a/INTERCHANGE.md +++ b/INTERCHANGE.md @@ -136,9 +136,9 @@ A range contains an inclusive `start` position and an exclusive `end` position. Implementations may emit non-`STK` diagnostics. Canonical fixtures only require portable `STK` diagnostics unless a case explicitly documents an implementation extension. -## 5. Canonical Conformance Suite +## 5. Canonical Compiler Conformance Suite -The canonical suite lives in [`conformance/`](./conformance). Each case is one directory named with a lowercase ASCII identifier. +The canonical compiler suite lives in `conformance/valid/` and `conformance/invalid/`. Each compiler case is one directory named with a lowercase ASCII identifier. Formatter fixtures use a separate contract and layout defined in the [Stack Canonical Formatter Specification](./FORMATTER.md). ### 5.1 Valid Cases diff --git a/README.md b/README.md index 5c067cf..c1e20af 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ The language is currently a proposal for Stack 1.0. No compatibility guarantee a ## Documents - [Language specification](./SPECIFICATION.md) +- [Canonical formatter specification](./FORMATTER.md) - [Compiler interchange specification](./INTERCHANGE.md) - [ADR-0001: Adopt a constrained declarative topology language](./docs/decisions/0001-constrained-declarative-language.md) - [ADR-0002: Make the canonical theme catalog own icons](./docs/decisions/0002-theme-owned-icons.md) @@ -59,9 +60,10 @@ Install the development requirements and validate the portable schemas and confo ```sh python -m pip install --requirement requirements-dev.txt check-jsonschema --check-metaschema schemas/*.json -check-jsonschema --schemafile schemas/normalized-ir.schema.json conformance/valid/*/expected.ir.json +check-jsonschema --schemafile schemas/normalized-ir.schema.json conformance/valid/*/expected.ir.json conformance/formatter/*/expected.ir.json find conformance -name expected.diagnostics.json -print0 | xargs -0 check-jsonschema --schemafile schemas/diagnostic-expectations.schema.json python scripts/validate-compiler-diagnostics.py +python scripts/validate-formatter-fixtures.py ``` ## Design Principles diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 5dccc89..0b9d97e 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -234,15 +234,9 @@ They remain valid identifiers where the grammar expects an identifier. This cont ### 5.2 Canonical Formatting -Formatting does not affect meaning. A canonical formatter SHOULD: - -- use two spaces per nesting level; -- put one declaration or property on each line; -- place opening braces on the declaration line; -- place the theme statement before layout and element declarations; -- include a blank line between top-level declarations; -- preserve comments where practical; -- preserve declaration order. +Formatting does not affect meaning. The normative canonical source representation, including comment placement, ordering, whitespace, string escaping, line endings, and conformance requirements, is defined in the [Stack Canonical Formatter Specification](./FORMATTER.md). + +A canonical formatter MUST preserve normalized meaning and comments, and formatting canonical source again MUST produce byte-identical output. ## 6. Document and Diagram Semantics diff --git a/conformance/README.md b/conformance/README.md index 0fca87b..401196b 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,6 +1,6 @@ # Stack Conformance Suite -This directory contains implementation-independent compiler conformance cases for the Stack language. +This directory contains implementation-independent compiler and formatter conformance cases for the Stack language. ## Layout @@ -13,6 +13,10 @@ valid//expected.diagnostics.json # optional invalid//source.stack invalid//expected.diagnostics.json + +formatter//input.stack +formatter//expected.stack +formatter//expected.ir.json ``` `source.stack` must be read as bytes. This permits future encoding-error fixtures even though valid Stack documents are UTF-8. @@ -23,6 +27,8 @@ The canonical suite covers every Stack 1.0 diagnostic assigned to compiler stage The encoding cases intentionally include raw invalid UTF-8, a UTF-8 byte order mark, CRLF line endings, and a Unicode scalar before an error position. Tools must preserve `source.stack` bytes rather than decoding and rewriting fixtures during discovery. +Formatter inputs are compiler-valid Stack documents. A formatter runner compares canonical source bytes, formats the expected source again to verify idempotence, and compiles both input and expected source to verify that each is semantically equal to `expected.ir.json`. The complete formatter behavior is defined in the [Stack Canonical Formatter Specification](../FORMATTER.md). + ## Comparison - JSON values are compared semantically; formatting and object-member order do not matter. diff --git a/conformance/formatter/comments-and-layout/expected.ir.json b/conformance/formatter/comments-and-layout/expected.ir.json new file mode 100644 index 0000000..9560d49 --- /dev/null +++ b/conformance/formatter/comments-and-layout/expected.ir.json @@ -0,0 +1,85 @@ +{ + "schemaVersion": "1.0", + "languageVersion": { + "major": 1, + "minor": 0 + }, + "title": "Formatter 図", + "themeId": "dark", + "children": [ + { + "type": "group", + "id": "services" + }, + { + "type": "node", + "id": "client" + } + ], + "nodes": [ + { + "id": "frontend", + "label": "Web app", + "kind": "client", + "iconId": "browser", + "detail": "Uses \"quotes\" and \\ paths", + "parentGroupId": "services" + }, + { + "id": "backend", + "label": "API 🚀", + "kind": "service", + "iconId": "service", + "detail": null, + "parentGroupId": "services" + }, + { + "id": "client", + "label": "Client", + "kind": "service", + "iconId": null, + "detail": null, + "parentGroupId": null + } + ], + "groups": [ + { + "id": "services", + "label": "Services", + "parentGroupId": null, + "children": [ + { + "type": "node", + "id": "frontend" + }, + { + "type": "node", + "id": "backend" + } + ], + "layout": null + } + ], + "edges": [ + { + "from": "client", + "to": "frontend", + "direction": "forward", + "kind": "request", + "label": "HTTPS" + } + ], + "layout": { + "direction": "right", + "sameRanks": [ + [ + "services", + "client" + ] + ], + "order": [ + "services", + "client" + ] + } +} diff --git a/conformance/formatter/comments-and-layout/expected.stack b/conformance/formatter/comments-and-layout/expected.stack new file mode 100644 index 0000000..ade110a --- /dev/null +++ b/conformance/formatter/comments-and-layout/expected.stack @@ -0,0 +1,33 @@ +// Leading file comment +stack 1.0 // Version comment + +diagram "Formatter 図" { + group services "Services" { + // Nested declaration comment + node frontend "Web app" { + detail "Uses \"quotes\" and \\ paths" + icon "browser" + kind client + } // Frontend node + + node backend "API 🚀" { + icon "service" // Backend icon + kind service + } + } + + // Between declarations + node client "Client" + + theme dark + + layout { + order [services, client] + direction right + rank same [services, client] + } + + edge client -> frontend "HTTPS" { + kind request + } +} diff --git a/conformance/formatter/comments-and-layout/input.stack b/conformance/formatter/comments-and-layout/input.stack new file mode 100644 index 0000000..68091e4 --- /dev/null +++ b/conformance/formatter/comments-and-layout/input.stack @@ -0,0 +1,19 @@ +// Leading file comment +stack 1 . 0// Version comment + + +diagram "Formatter \u56F3"{ + group services "Services"{ +// Nested declaration comment +node frontend "\u0057eb app" {detail "Uses \u0022quotes\u0022 and \\ paths" icon "browser" kind client}// Frontend node + + +node backend "API \uD83D\uDE80"{icon "service"// Backend icon +kind service} +} +// Between declarations +node client "Client" +theme dark +layout {order[services,client] direction right rank same[services,client]} +edge client->frontend "HTTPS"{kind request} +} diff --git a/scripts/validate-formatter-fixtures.py b/scripts/validate-formatter-fixtures.py new file mode 100644 index 0000000..8568686 --- /dev/null +++ b/scripts/validate-formatter-fixtures.py @@ -0,0 +1,51 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "conformance" / "formatter" +REQUIRED_FILES = {"input.stack", "expected.stack", "expected.ir.json"} + + +def decode_utf8(path: Path, data: bytes) -> str: + if data.startswith(b"\xef\xbb\xbf"): + raise ValueError(f"{path}: UTF-8 byte order mark is not allowed") + try: + return data.decode("utf-8") + except UnicodeDecodeError as error: + raise ValueError(f"{path}: source is not valid UTF-8") from error + + +def validate_expected(path: Path, data: bytes) -> None: + decode_utf8(path, data) + if b"\r" in data: + raise ValueError(f"{path}: canonical output must use LF line endings") + if not data.endswith(b"\n") or data.endswith(b"\n\n"): + raise ValueError(f"{path}: canonical output must end with exactly one LF") + + +def main() -> None: + cases = sorted(path for path in FIXTURES.iterdir() if path.is_dir()) + if not cases: + raise ValueError(f"{FIXTURES}: no formatter cases found") + + has_crlf_input = False + for case in cases: + actual_files = {path.name for path in case.iterdir() if path.is_file()} + if actual_files != REQUIRED_FILES: + raise ValueError( + f"{case}: expected files {sorted(REQUIRED_FILES)}, " + f"found {sorted(actual_files)}" + ) + + input_data = (case / "input.stack").read_bytes() + expected_data = (case / "expected.stack").read_bytes() + decode_utf8(case / "input.stack", input_data) + validate_expected(case / "expected.stack", expected_data) + has_crlf_input = has_crlf_input or b"\r\n" in input_data + + if not has_crlf_input: + raise ValueError(f"{FIXTURES}: at least one input must exercise CRLF normalization") + + +if __name__ == "__main__": + main()