From 217563a8e2a5ad32c3b90c005f0b9ab4f6481443 Mon Sep 17 00:00:00 2001 From: Elias Mueller Date: Fri, 24 Apr 2026 14:29:46 +0200 Subject: [PATCH 1/8] test: clean test cache --- Justfile | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Justfile b/Justfile index 623bf4b..822003c 100755 --- a/Justfile +++ b/Justfile @@ -11,20 +11,24 @@ init: go install honnef.co/go/tools/cmd/staticcheck@2025.1.1 go install github.com/securego/gosec/v2/cmd/gosec@v2.22.10 +# Build the qmlimportsort binary +@build: + go build -o qmlimportsort ./cmd/qmlimportsort + +[group('dev')] format: prek run --all-files go fmt ./... +[group('dev')] lint: staticcheck ./... gosec \ -exclude=G304 \ -quiet ./... -# Build the qmlimportsort binary -@build: - go build -o qmlimportsort ./cmd/qmlimportsort - # Run all tests +[group('dev')] @test *FLAGS: - go test ./... {{ FLAGS }} + go clean -testcache + go test ./... {{ FLAGS }} From 0de1e5a8aedafff1cbef12a7f303cba5271bf11b Mon Sep 17 00:00:00 2001 From: Elias Mueller Date: Fri, 24 Apr 2026 16:18:36 +0200 Subject: [PATCH 2/8] chore: add formatter belt --- .config/biome.jsonc | 23 +++++++++++++ .config/prek.toml | 73 +++++++++++++++++++++++++++++++++++++++++ .config/tombi.toml | 14 ++++++++ .config/yamlfmt.yaml | 8 +++++ .pre-commit-config.yaml | 28 ---------------- Justfile | 15 +++++---- 6 files changed, 127 insertions(+), 34 deletions(-) create mode 100644 .config/biome.jsonc create mode 100644 .config/prek.toml create mode 100644 .config/tombi.toml create mode 100644 .config/yamlfmt.yaml delete mode 100644 .pre-commit-config.yaml diff --git a/.config/biome.jsonc b/.config/biome.jsonc new file mode 100644 index 0000000..b40aa87 --- /dev/null +++ b/.config/biome.jsonc @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Elias Mueller +// +// SPDX-License-Identifier: MIT + +{ + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", + "formatter": { + "indentStyle": "space", + "indentWidth": 4, + "lineEnding": "lf", + "lineWidth": 120 + }, + "javascript": { + "formatter": { + "bracketSpacing": true + } + }, + "json": { + "formatter": { + "bracketSpacing": true + } + } +} diff --git a/.config/prek.toml b/.config/prek.toml new file mode 100644 index 0000000..b094a67 --- /dev/null +++ b/.config/prek.toml @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Elias Mueller +# +# SPDX-License-Identifier: MIT + +# Configuration file for `prek`, a git hook framework written in Rust. +# See https://prek.j178.dev for more information. +#:schema https://www.schemastore.org/prek.json + +[[repos]] +repo = "builtin" +hooks = [ + { id = "end-of-file-fixer" }, + { id = "trailing-whitespace" }, +] + +[[repos]] +repo = "https://github.com/biomejs/pre-commit" +rev = "v2.4.13" +hooks = [ + { + id = "biome-check", + name = "format json", + files = '\.(jsonc?)$', + exclude = '\.config/biome\.jsonc', + args = ["--config-path=.config"], + }, +] + +[[repos]] +repo = "https://github.com/tombi-toml/tombi-pre-commit" +rev = "v0.9.21" +hooks = [ + { + id = "tombi-format", + name = "format toml", + exclude = "uv.lock", + args = [ + "--offline", + ] + }, +] + +[[repos]] +repo = "https://github.com/google/yamlfmt" +rev = "v0.21.0" +hooks = [ + { + id = "yamlfmt", + name = "format yml", + args = ["-conf", ".config/yamlfmt.yaml"], + exclude = ".config/goreleaser.yml" + }, +] + +[[repos]] +repo = "https://github.com/hukkin/mdformat" +rev = "1.0.0" +hooks = [ + { + id = "mdformat", + name = "format md", + exclude = ".github/", + args = ["--number", "--wrap", "keep", "--end-of-line", "lf"], + additional_dependencies = ["mdformat-gfm"] + }, +] + +[[repos]] +repo = "https://github.com/fsfe/reuse-tool" +rev = "v6.2.0" +hooks = [ + { id = "reuse-lint-file", name = "lint licenses" }, +] diff --git a/.config/tombi.toml b/.config/tombi.toml new file mode 100644 index 0000000..3576abe --- /dev/null +++ b/.config/tombi.toml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Elias Mueller +# +# SPDX-License-Identifier: MIT + +toml-version = "v1.1.0" + +[format] +[format.rules] +indent-style = "space" +indent-width = 4 +line-ending = "lf" +line-width = 120 +string-quote-style = "double" +trailing-comment-alignment = true diff --git a/.config/yamlfmt.yaml b/.config/yamlfmt.yaml new file mode 100644 index 0000000..78222e5 --- /dev/null +++ b/.config/yamlfmt.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Elias Mueller +# +# SPDX-License-Identifier: MIT + +formatter: + line_ending: lf + retain_line_breaks_single: true + scan_folded_as_literal: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 4390b9a..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-FileCopyrightText: Elias Mueller -# -# SPDX-License-Identifier: MIT - -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - - id: end-of-file-fixer - - id: trailing-whitespace - - repo: https://github.com/google/yamlfmt - rev: v0.19.0 - hooks: - - id: yamlfmt - name: "format yml" - exclude: ^.config/ - args: - - "-formatter" - - "line_ending=lf" - - "-formatter" - - "retain_line_breaks_single=true" - - "-formatter" - - "scan_folded_as_literal=true" - - repo: https://github.com/fsfe/reuse-tool - rev: v6.1.2 - hooks: - - id: reuse-lint-file - name: "lint licenses" diff --git a/Justfile b/Justfile index 822003c..ce0862d 100755 --- a/Justfile +++ b/Justfile @@ -11,15 +11,13 @@ init: go install honnef.co/go/tools/cmd/staticcheck@2025.1.1 go install github.com/securego/gosec/v2/cmd/gosec@v2.22.10 -# Build the qmlimportsort binary -@build: - go build -o qmlimportsort ./cmd/qmlimportsort - -[group('dev')] format: - prek run --all-files + uv run prek --config .config/prek.toml run --all-files go fmt ./... +update-git-hooks: + uv run prek --config .config/prek.toml auto-update + [group('dev')] lint: staticcheck ./... @@ -32,3 +30,8 @@ lint: @test *FLAGS: go clean -testcache go test ./... {{ FLAGS }} + +# Build the qmlimportsort binary +[group('build')] +@build: + go build -o qmlimportsort ./cmd/qmlimportsort From 529d5256df0b9f501a3bf6e2f9c2456fe4d3c4d6 Mon Sep 17 00:00:00 2001 From: Elias Mueller Date: Fri, 24 Apr 2026 16:07:32 +0200 Subject: [PATCH 3/8] docs: document next steps --- .config/goreleaser.yml | 4 + README.MD | 14 ++-- ROADMAP.md | 12 +++ docs/devel/CLI.md | 97 ++++++++++++++++++++++++ docs/devel/INTERNAL_API.md | 148 +++++++++++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 ROADMAP.md create mode 100644 docs/devel/CLI.md create mode 100644 docs/devel/INTERNAL_API.md diff --git a/.config/goreleaser.yml b/.config/goreleaser.yml index 98647a5..1fc8920 100644 --- a/.config/goreleaser.yml +++ b/.config/goreleaser.yml @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Elias Mueller +# +# SPDX-License-Identifier: MIT + # This is an example .goreleaser.yml file with some sensible defaults. # Make sure to check the documentation at https://goreleaser.com diff --git a/README.MD b/README.MD index 623ac2c..c604cb2 100644 --- a/README.MD +++ b/README.MD @@ -80,13 +80,15 @@ Download pre-built binaries from the releases page. ## Usage - USAGE: - qmlimportsort [flags] [files...] +``` +USAGE: + qmlimportsort [flags] [files...] - FLAGS: - --in-place, -i modify files in-place (only valid with files) - --help, -h show help - --version, -v print the version +FLAGS: + --in-place, -i modify files in-place (only valid with files) + --help, -h show help + --version, -v print the version +``` ### Examples diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..07b69b7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,12 @@ + + +# Roadmap + +## Refactor + +1. **Design the CLI** — ✅ Done. Agreed surface: write-by-default, `--check` / `--stdout` / `--stdin` modes, recursive directory walking, skip dotfiles, atomic writes. Full spec in [docs/devel/CLI.md](docs/devel/CLI.md). +2. **Design the internal API** — ✅ Done. Split into `internal/qml` (pure `Format([]byte)`) and `internal/fs` (I/O shell: walker, atomic writes, stream/file helpers). `main` is a thin dispatcher. Full spec in [docs/devel/INTERNAL_API.md](docs/devel/INTERNAL_API.md). diff --git a/docs/devel/CLI.md b/docs/devel/CLI.md new file mode 100644 index 0000000..2321d6b --- /dev/null +++ b/docs/devel/CLI.md @@ -0,0 +1,97 @@ + + +# CLI Design + +## 2026-04-24 + +### Synopsis + +``` +qmlimportsort [flags] ... +qmlimportsort --stdin [flags] +``` + +Each `` is a file or directory. Directories are walked recursively. + +### Invocations + +| Command | Behavior | +| ------------------------------ | ---------------------------------------------------------------- | +| `qmlimportsort a.qml` | Format `a.qml` in place. | +| `qmlimportsort a.qml b.qml` | Format both in place. | +| `qmlimportsort src/` | Recurse under `src/`, format every `*.qml` file in place. | +| `qmlimportsort src/ main.qml` | Mix: format files under `src/` and `main.qml` in place. | +| `qmlimportsort --stdin` | Read stdin, write formatted content to stdout. | +| `qmlimportsort --check src/` | Dry-run. Print paths that would change to stdout. Exit 1 if any. | +| `qmlimportsort --stdout a.qml` | Print formatted content of `a.qml` to stdout. Don't write. | +| `qmlimportsort --version` | Print version, exit 0. | +| `qmlimportsort --help` | Print usage, exit 0. | +| `qmlimportsort` (no args) | Print usage to stderr, exit 2. | + +### Flags + +| Flag | Short | Purpose | +| ----------- | ----- | ------------------------------------------------------------------------------------ | +| `--check` | `-c` | Don't write. Print paths that would change to stdout, one per line. Exit 1 if any. | +| `--stdout` | | Don't write. Print formatted content to stdout. Single file only (see restrictions). | +| `--stdin` | | Read from stdin, write to stdout. Mutually exclusive with positional paths. | +| `--version` | | Print version, exit 0. | +| `--help` | `-h` | Print usage, exit 0. | + +### Flag combinations + +- `--check` and `--stdout` are mutually exclusive (usage error, exit 2). +- `--stdin` is mutually exclusive with positional paths (usage error, exit 2). +- `--stdin` combined with `--check`: dry-run on stdin content. Exit 1 if it would change, 0 otherwise. No output on + stdout. +- `--stdin` combined with `--stdout`: redundant but allowed — stdin already goes to stdout. +- `--stdout` requires exactly one input that is a file. Passing a directory, or more than one path, with `--stdout` is a + usage error (exit 2). Rationale: no unambiguous way to concatenate multiple files' output. + +### Directory walking + +- **Recurse** into directories fully. +- **Match**: files whose name ends in `.qml` (case-sensitive). +- **Skip** any directory or file whose name starts with `.` during recursion (e.g. `.git`, `.venv`, `.idea`). Explicitly + passing such a path as an argument still processes it — the skip rule applies only while walking. +- **Don't follow symlinks** (neither directory nor file symlinks). +- **Empty results** (directory with no matching `.qml` files): silent success, exit 0. + +### Error handling + +- **Per-file parse/IO errors**: log to stderr, continue with remaining inputs. +- **Path doesn't exist**: treated as a per-input error (stderr + continue + exit 2 at end), not a fatal abort. +- **Exit code**: if any input produced an error, exit 2 after processing all inputs. Otherwise exit 0 (or 1 in `--check` + mode if changes would be needed). + +### Exit codes + +| Code | Meaning | +| ---- | --------------------------------------------------------------------------------- | +| 0 | Success. Write mode: all inputs processed cleanly. Check mode: no changes needed. | +| 1 | `--check` mode only: at least one file would change. | +| 2 | Usage error, or one or more inputs failed (missing file, parse error, IO error). | + +### Behavior guarantees + +- **Idempotent**: `qmlimportsort x.qml && qmlimportsort --check x.qml` always exits 0 on the second call. +- **Atomic writes**: write formatted content to a temp file in the same directory, then `rename(2)` over the original. + Prevents truncation on crash. +- **Preserve**: line endings (`\n` / `\r\n` / `\r`) and file mode. +- **No backups**: no `.bak` files. Users have VCS. +- **No progress output** on success in write mode — silence means success. Errors go to stderr. +- **Processing order**: inputs are processed in the order given on the command line. Within a directory, entries are + processed in lexical order (sorted) so `--check` output is deterministic. + +### Out of scope (deferred) + +- Subcommands (`format`, `check`, etc.) — can be added later non-breakingly if a genuinely new verb shows up. +- `.gitignore` / `.qmlimportsortignore` support. +- `--exclude` / `--include` patterns. +- `--follow-symlinks`. +- Configurable file extensions. +- Parallel processing. diff --git a/docs/devel/INTERNAL_API.md b/docs/devel/INTERNAL_API.md new file mode 100644 index 0000000..b5f6232 --- /dev/null +++ b/docs/devel/INTERNAL_API.md @@ -0,0 +1,148 @@ + + +# Internal API Design + +## 2026-04-24 + +### Principles + +- **Pure core, I/O shell.** The parsing/formatting logic takes bytes in and returns bytes out. Everything else (files, directories, stdin, atomic writes) is a thin I/O layer on top. +- **Compiler-enforced layering.** The pure core lives in a package that does not import `os` or `io`. The Go compiler prevents accidental coupling. +- **`main` is a dispatcher.** Flag parsing, exit codes, and stderr messages live in `main`. All tool policy (file filters, dotfile skip, atomic writes, line-ending preservation) lives in `internal`. + +### Package layout + +``` +internal/ +├── qml/ # Pure: formatting logic. No I/O imports. +└── fs/ # I/O shell: file reads, directory walks, atomic writes, stdin/stdout glue. +``` + +`main` imports `internal/fs`. `internal/fs` imports `internal/qml`. `internal/qml` imports only the stdlib's pure packages (`strings`, `regexp`, `sort`/`slices`, `bytes`, `errors`, `fmt`). + +______________________________________________________________________ + +### Package `internal/qml` + +Exports exactly one function. All parsing, classification, and reassembly logic stays unexported. + +```go +// Format sorts and groups QML imports in src, returning the formatted bytes. +// The input's line endings (\n, \r\n, or \r) are detected and preserved +// in the output. src is not modified. +// +// Comments inside the import block are preserved: each comment line is +// attached to the following import and travels with it through sorting. +// +// A file with no imports (and no pragmas) is a valid input and is +// returned unchanged. +// +// Returns an error if the input cannot be parsed — specifically, if a +// line inside the pragma/import block cannot be classified as a pragma, +// import, blank line, or comment. +func Format(src []byte) ([]byte, error) +``` + +The package will need unexported helpers for (a) detecting the input's line ending, (b) locating the pragma/import block within the document, (c) classifying each line into one of the import categories (pragma, Qt, library, module, relative), and (d) reassembling the categories back into output bytes. The exact shape of these helpers is an implementation detail and not part of the API contract. + +**Implementation approach: line tokenizer.** A single pass over the import block produces a slice of tokens of the form `{kind, text, leadingComments []string}`, where `kind` is one of the five categories. Sorting and grouping operate on that slice, and output is reassembled by walking the sorted tokens. + +Comments inside the block are preserved: while scanning, contiguous comment lines accumulate into a buffer and attach as `leadingComments` to the next import or pragma token. On emit, each token writes its leading comments (in original order) before the import line itself. Sorting operates only on `token.text`, so comments travel with their import to its final sorted position. + +**Why this shape** + +- Bytes-in/bytes-out is the simplest possible contract. Any caller that can produce bytes (file, stdin, in-memory buffer, test fixture) can use it. +- Line-ending detection is content inspection, not I/O — it belongs with the pure core. +- Single export forces the public surface to stay small. Anything else is an implementation detail. + +______________________________________________________________________ + +### Package `internal/fs` + +The I/O shell. Wraps `qml.Format` with the file operations the CLI needs. + +```go +// FormatStream reads QML content from src, formats it via qml.Format, +// and writes the result to dst. +// Returns (changed, err) where changed reports whether the formatted +// output differs byte-for-byte from the input. +// +// Used by: --stdin (dst = os.Stdout), --stdin --check (dst = io.Discard). +func FormatStream(src io.Reader, dst io.Writer) (changed bool, err error) + +// FormatFile formats path in place using an atomic write (temp file +// in the same directory + rename). +// Returns (changed, err) where changed reports whether the file's +// content on disk differs after formatting. +// File mode is preserved across the rename. +// +// Used by: default write mode. +func FormatFile(path string) (changed bool, err error) + +// CheckFile reports whether formatting path would change its content. +// Does not write. +// +// Used by: --check. +func CheckFile(path string) (wouldChange bool, err error) + +// FormatFileTo reads path, formats it, writes to dst. +// Does not modify the file on disk. +// +// Used by: --stdout. +func FormatFileTo(path string, dst io.Writer) error + +// WalkQMLFiles walks root recursively, calling fn(path) for each regular +// file whose name ends in ".qml". Entries whose name begins with "." +// are skipped during descent. Symlinks are not followed. +// +// The root argument itself is processed regardless of a leading dot +// (explicit paths bypass the skip rule). +// +// If fn returns an error, the walk stops and that error is returned. +// +// Used by: all modes that accept directory arguments. +func WalkQMLFiles(root string, fn func(path string) error) error +``` + +**Unexported helpers inside `fs`**: + +- `writeAtomic(path string, data []byte, mode os.FileMode) error` — temp file + rename. +- `readAndFormat([]byte) (out []byte, changed bool, err error)` — shared by `FormatStream`/`FormatFile`/`CheckFile` to avoid duplicating the "did anything change?" comparison. + +**Error handling**: + +- All exported `fs` functions wrap path/IO errors with `fmt.Errorf("%s: %w", path, err)` so callers can use `errors.Is` / `errors.As`. +- Parse errors from `qml.Format` are wrapped with the path as well. + +______________________________________________________________________ + +### How the CLI modes compose + +Each flag mode in [CLI.md](CLI.md) maps to a small combination of the primitives above. `main` contains no formatting logic — only dispatch. + +| Mode | Composition | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `qmlimportsort a.qml` | `fs.FormatFile("a.qml")` | +| `qmlimportsort src/` | `fs.WalkQMLFiles("src/", fs.FormatFile)` — except `fn` needs to match `func(string) error`, so wrap: `fn := func(p string) error { _, err := fs.FormatFile(p); return err }` | +| `qmlimportsort --check src/` | `fs.WalkQMLFiles("src/", func(p) { if change, _ := fs.CheckFile(p); change { print(p); anyChanged = true } })` | +| `qmlimportsort --stdout a.qml` | `fs.FormatFileTo("a.qml", os.Stdout)` | +| `qmlimportsort --stdin` | `fs.FormatStream(os.Stdin, os.Stdout)` | +| `qmlimportsort --stdin --check` | `changed, _ := fs.FormatStream(os.Stdin, io.Discard)` — exit 1 if `changed` | + +______________________________________________________________________ + +### Implementation note + +The refactor is a fresh rewrite — no code from the current `internal/` or `cmd/` tree is being ported over. The existing files (`internal/qml.go`, `internal/orchestrator.go`, `internal/document.go`, `internal/file.go`, `internal/qml_test.go`, `cmd/qmlimportsort/main.go`) will be replaced wholesale. Tests are rewritten against the new API surface, not adapted from the old ones. + +______________________________________________________________________ + +### Open points deliberately not specified here + +- **Concrete error types / sentinel errors**: not needed yet. If CI tooling grows to want to distinguish parse errors from IO errors, we can introduce `var ErrParse = errors.New(...)` later. +- **Streaming / chunked formatting**: not needed. QML files are small; read fully into memory. +- **Context / cancellation**: no long-running operations; no `context.Context` in the API. From 2becdae5f82c259b1eeaf4ac83b4f643fca4402a Mon Sep 17 00:00:00 2001 From: Elias Mueller Date: Fri, 24 Apr 2026 16:22:13 +0200 Subject: [PATCH 4/8] chore: delete first implementation --- cmd/qmlimportsort/main.go | 63 ------- go.mod | 2 - go.sum | 10 -- internal/document.go | 71 -------- internal/file.go | 41 ----- internal/orchestrator.go | 79 --------- internal/qml.go | 155 ----------------- internal/qml_test.go | 348 -------------------------------------- 8 files changed, 769 deletions(-) delete mode 100644 cmd/qmlimportsort/main.go delete mode 100644 internal/document.go delete mode 100644 internal/file.go delete mode 100644 internal/orchestrator.go delete mode 100644 internal/qml.go delete mode 100644 internal/qml_test.go diff --git a/cmd/qmlimportsort/main.go b/cmd/qmlimportsort/main.go deleted file mode 100644 index 523cc5b..0000000 --- a/cmd/qmlimportsort/main.go +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: Elias Mueller -// -// SPDX-License-Identifier: MIT - -package main - -import ( - "context" - "fmt" - "log" - "os" - - "github.com/trin94/qml-import-sort/internal" - "github.com/urfave/cli/v3" -) - -var customHelpText = `USAGE: - qmlimportsort [flags] [files...] -{{if .VisibleFlags}} -FLAGS:{{template "visibleFlagTemplate" .}}{{end}} -` - -func main() { - log.SetFlags(0) - - err := command().Run(context.Background(), os.Args) - - if err != nil { - log.Printf("%+v", err) - os.Exit(1) - } -} - -func command() *cli.Command { - cli.RootCommandHelpTemplate = customHelpText - cli.VersionPrinter = func(cmd *cli.Command) { - fmt.Printf("qmlimportsort %s\n", cmd.Root().Version) - } - - return &cli.Command{ - Name: "qmlimportsort", - Version: "v0.1.0", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "in-place", - Aliases: []string{"i"}, - Usage: "modify files in-place (only valid with files)", - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - files := cmd.Args().Slice() - inPlace := cmd.Bool("in-place") - if inPlace && len(files) == 0 { - return cli.Exit("error: --in-place can only be used with files", 1) - } - if len(files) > 0 { - return internal.ProcessFiles(files, inPlace) - } else { - return internal.ProcessStdIn(os.Stdin) - } - }, - } -} diff --git a/go.mod b/go.mod index c448c89..562601a 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,3 @@ module github.com/trin94/qml-import-sort go 1.24.0 - -require github.com/urfave/cli/v3 v3.5.0 diff --git a/go.sum b/go.sum index edf3694..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/urfave/cli/v3 v3.5.0 h1:qCuFMmdayTF3zmjG8TSsoBzrDqszNrklYg2x3g4MSgw= -github.com/urfave/cli/v3 v3.5.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/document.go b/internal/document.go deleted file mode 100644 index 680bdad..0000000 --- a/internal/document.go +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-FileCopyrightText: Elias Mueller -// -// SPDX-License-Identifier: MIT - -package internal - -import ( - "fmt" - "io" - "os" - "strings" -) - -type document struct { - path string - lineEnding string - lines []string -} - -func newDocument(path string) (*document, error) { - content, err := os.ReadFile(path) - if err != nil { - return nil, errorWithPath(err, path) - } - lineEnding := detectLineEnding(content) - lines := strings.Split(string(content), lineEnding) - return &document{ - path: path, - lineEnding: lineEnding, - lines: lines, - }, nil -} - -func newDocumentFromReader(r io.Reader) (*document, error) { - content, err := io.ReadAll(r) - if err != nil { - return nil, err - } - lineEnding := detectLineEnding(content) - lines := strings.Split(string(content), lineEnding) - return &document{ - path: "", - lineEnding: lineEnding, - lines: lines, - }, nil -} - -func (d *document) organize() error { - var err error - d.lines, err = processLines(d.lines) - return errorWithPath(err, d.path) -} - -func (d *document) writeBack() error { - err := os.WriteFile(d.path, []byte(strings.Join(d.lines, d.lineEnding)), 0600) - return errorWithPath(err, d.path) -} - -func (d *document) string() string { - return strings.Join(d.lines, d.lineEnding) -} - -func errorWithPath(err error, path string) error { - if err == nil { - return nil - } - if path == "" { - return err - } - return fmt.Errorf("file: %s: %v", path, err.Error()) -} diff --git a/internal/file.go b/internal/file.go deleted file mode 100644 index 7a299d5..0000000 --- a/internal/file.go +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-FileCopyrightText: Elias Mueller -// -// SPDX-License-Identifier: MIT - -package internal - -import ( - "fmt" - "os" - "path/filepath" -) - -func resolveFiles(files []string) ([]string, error) { - var existing []string - for _, file := range files { - abs, err := filepath.Abs(file) - if err != nil { - return nil, err - } - if _, err := os.Stat(abs); os.IsNotExist(err) { - return nil, fmt.Errorf("file '%s' does not exist", file) - } - existing = append(existing, abs) - } - return existing, nil -} - -func detectLineEnding(content []byte) string { - for i := 0; i < len(content); i++ { - if content[i] == '\r' { - if i+1 < len(content) && content[i+1] == '\n' { - return "\r\n" - } - return "\r" - } - if content[i] == '\n' { - return "\n" - } - } - return "\n" -} diff --git a/internal/orchestrator.go b/internal/orchestrator.go deleted file mode 100644 index 16f7813..0000000 --- a/internal/orchestrator.go +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-FileCopyrightText: Elias Mueller -// -// SPDX-License-Identifier: MIT - -package internal - -import ( - "fmt" - "io" -) - -func ProcessFiles(files []string, inPlace bool) error { - files, err := resolveFiles(files) - if err != nil { - return err - } - for _, file := range files { - doc, err := newDocument(file) - if err != nil { - return err - } - if err := doc.organize(); err != nil { - return err - } - if inPlace { - if err := doc.writeBack(); err != nil { - return err - } - } else { - fmt.Print(doc.string()) - } - } - return nil -} - -func ProcessStdIn(reader io.Reader) error { - doc, err := newDocumentFromReader(reader) - if err != nil { - return err - } - if err := doc.organize(); err != nil { - return err - } - fmt.Print(doc.string()) - return nil -} - -func processLines(lines []string) ([]string, error) { - start, end, err := identifyRelevantLines(lines) - if err != nil { - return nil, err - } - organized, err := organizeQmlHeaderStatements(lines[start : end+1]) - if err != nil { - return nil, err - } - - // calculate capacity - capacity := len(lines[:start]) + len(organized) + len(lines[end+1:]) - if start > 0 { - capacity++ - } - if end+1 < len(lines) { - capacity++ - } - - // join final lines - result := make([]string, 0, capacity) - result = append(result, lines[:start]...) - if start > 0 { - result = append(result, "") - } - result = append(result, organized...) - if end+1 < len(lines) { - result = append(result, "") - } - result = append(result, lines[end+1:]...) - return result, nil -} diff --git a/internal/qml.go b/internal/qml.go deleted file mode 100644 index def847f..0000000 --- a/internal/qml.go +++ /dev/null @@ -1,155 +0,0 @@ -// SPDX-FileCopyrightText: Elias Mueller -// -// SPDX-License-Identifier: MIT - -package internal - -import ( - "errors" - "fmt" - "regexp" - "sort" - "strings" -) - -var ( - normalizeImportWhitespace = regexp.MustCompile(`^import\s+`) - firstStatement = regexp.MustCompile(`^[a-zA-Z0-9.]+\s*.?\{.*`) - - qtImportRegex = regexp.MustCompile(`^import\s+Qt[A-Z5.].*`) - libraryImportRegex = regexp.MustCompile(`^import\s+[a-zA-Z]+\..*`) - moduleImportRegex = regexp.MustCompile(`^import\s+[a-zA-Z]\w*(\s|$)`) - relativeImportRegex = regexp.MustCompile(`^import\s+(["']).*`) -) - -// identifyRelevantLines returns the start index (inclusive) and end index (inclusive) -func identifyRelevantLines(lines []string) (start, end int, err error) { - var consecutiveBlankLines int - var startFound bool - - for index, line := range lines { - line = strings.TrimSpace(line) - - if !startFound { - if line == "" { - consecutiveBlankLines++ - } else if isComment(line) { - consecutiveBlankLines = 0 - } else if isPragma(line) || strings.HasPrefix(line, "import ") { - start = index - consecutiveBlankLines - startFound = true - } - continue - } - - if firstStatement.MatchString(line) { - return start, index - 1, nil - } - } - - return -1, -1, errors.New("could not identify relevant lines") -} - -// isComment returns if a line starts with //, /*, *, or */ -func isComment(line string) bool { - return strings.HasPrefix(line, "//") || - strings.HasPrefix(line, "/*") || - strings.HasPrefix(line, "*") || - strings.HasPrefix(line, "*/") -} - -func isPragma(line string) bool { - return strings.HasPrefix(line, "pragma ") -} - -func organizeQmlHeaderStatements(lines []string) ([]string, error) { - pragmas := make([]string, 0, len(lines)) - qt := make([]string, 0, len(lines)) - library := make([]string, 0, len(lines)) - module := make([]string, 0, len(lines)) - relative := make([]string, 0, len(lines)) - - // identify import type - for i, line := range lines { - line = strings.TrimSpace(line) - - if line == "" || isComment(line) { - continue - } - - line = normalizeImportWhitespace.ReplaceAllString(line, "import ") - - if isPragma(line) { - pragmas = append(pragmas, line) - } else if isQtImport(line) { - qt = append(qt, line) - } else if isLibraryImport(line) { - library = append(library, line) - } else if isModuleImport(line) { - module = append(module, line) - } else if isRelativeImport(line) { - relative = append(relative, line) - } else { - return nil, fmt.Errorf("cannot identify import type (one of qt, library, module, relative) in line %d: '%s'", i, line) - } - } - - var sections [][]string - if len(pragmas) > 0 { - sections = append(sections, pragmas) - } - if len(qt) > 0 { - sections = append(sections, qt) - } - if len(library) > 0 { - sections = append(sections, library) - } - if len(module) > 0 { - sections = append(sections, module) - } - if len(relative) > 0 { - sections = append(sections, relative) - } - - // sort & calculate size needed - sizeRequirement := 0 - for _, section := range sections { - sort.Strings(section) - sizeRequirement += len(section) - } - - if len(sections) > 1 { - sizeRequirement += len(sections) - 1 - } - - // concat imports - result := make([]string, 0, sizeRequirement) - for idx, section := range sections { - result = append(result, section...) - if idx != len(sections)-1 { - result = append(result, "") - } - } - - return result, nil -} - -// isQtImport returns true if a line matches something like "import Qt...", false else. -func isQtImport(line string) bool { - return qtImportRegex.MatchString(line) -} - -// isLibraryImport returns true if a line matches something like "import io.github.whatever", false else. -func isLibraryImport(line string) bool { - return libraryImportRegex.MatchString(line) -} - -// isModuleImport returns true if a line matches something like "import MyModule" or "import mymodule", false else. -func isModuleImport(line string) bool { - return moduleImportRegex.MatchString(line) -} - -// isRelativeImport returns true if a line matches something like >import "< or >import '< -func isRelativeImport(line string) bool { - return relativeImportRegex.MatchString(line) -} diff --git a/internal/qml_test.go b/internal/qml_test.go deleted file mode 100644 index e1dd1c1..0000000 --- a/internal/qml_test.go +++ /dev/null @@ -1,348 +0,0 @@ -// SPDX-FileCopyrightText: Elias Mueller -// -// SPDX-License-Identifier: MIT - -package internal - -import ( - "reflect" - "testing" -) - -func TestIsQtImport(t *testing.T) { - for _, test := range []struct { - line string - expected bool - }{ - {line: "import QtCore", expected: true}, - {line: "import QtGraphicalEffects", expected: true}, - {line: "import QtLocation", expected: true}, - {line: "import QtMultimedia", expected: true}, - {line: "import QtNetwork", expected: true}, - {line: "import QtPositioning", expected: true}, - {line: "import Qt.", expected: true}, - {line: "import QtQml", expected: true}, - {line: "import QtQml //", expected: true}, - {line: "import QtQml /*", expected: true}, - {line: "import QtQml.", expected: true}, - {line: "import QtQml 2", expected: true}, - {line: "import QtQuick", expected: true}, - {line: "import QtQuick.", expected: true}, - {line: "import QtQuick 2", expected: true}, - {line: "import QtTest", expected: true}, - {line: "import QtTest 2", expected: true}, - - {line: "", expected: false}, - {line: "import Qt", expected: false}, - {line: "import MyModule", expected: false}, - {line: "import io.github.what", expected: false}, - {line: "import \"\"", expected: false}, - } { - actual := isQtImport(test.line) - if actual != test.expected { - t.Errorf("isQtImport(%q) = %v; expected %v", test.line, actual, test.expected) - } - } -} - -func TestIsLibraryImport(t *testing.T) { - for _, test := range []struct { - line string - expected bool - }{ - {line: "import io.github.what", expected: true}, - {line: "import io.github.what //", expected: true}, - - {line: "", expected: false}, - {line: "import Qt", expected: false}, - {line: "import MyModule", expected: false}, - {line: "import \"\"", expected: false}, - } { - actual := isLibraryImport(test.line) - if actual != test.expected { - t.Errorf("isLibraryImport(%q) = %v; expected %v", test.line, actual, test.expected) - } - } -} - -func TestIsModuleImport(t *testing.T) { - for _, test := range []struct { - line string - expected bool - }{ - {line: "import pyobjects //", expected: true}, - {line: "import MyModule", expected: true}, - {line: "import MyModule //", expected: true}, - {line: "import MyModule // io.github", expected: true}, - - {line: "", expected: false}, - {line: "import pyobjects.x", expected: false}, - {line: "import io.github", expected: false}, - {line: "import io.github.what //", expected: false}, - {line: "import \"\"", expected: false}, - } { - actual := isModuleImport(test.line) - if actual != test.expected { - t.Errorf("isModuleImport(%q) = %v; expected %v", test.line, actual, test.expected) - } - } -} - -func TestIsRelativeImport(t *testing.T) { - for _, test := range []struct { - line string - expected bool - }{ - {line: "import '.", expected: true}, - {line: "import \".", expected: true}, - {line: "import \"\"", expected: true}, - - {line: "", expected: false}, - {line: "import pyobjects", expected: false}, - {line: "import io.github.what", expected: false}, - } { - actual := isRelativeImport(test.line) - if actual != test.expected { - t.Errorf("isRelativeImport(%q) = %v; expected %v", test.line, actual, test.expected) - } - } -} - -func TestOrganizeQmlHeaderStatements(t *testing.T) { - for _, test := range []struct { - input []string - expected []string - }{ - { - input: []string{}, - expected: []string{}, - }, - { - input: []string{ - "import QtQuick", - }, - expected: []string{ - "import QtQuick", - }, - }, - { - input: []string{ - " import QtQuick", - "import Qt5Compat.GraphicalEffects", - }, - expected: []string{ - "import Qt5Compat.GraphicalEffects", - "import QtQuick", - }, - }, - { - input: []string{ - " //", - " import QtQuick", - " */", - " *", - "/*", - "// import QtQuick.Controls", - }, - expected: []string{ - "import QtQuick", - }, - }, - { - input: []string{ - "", - "pragma ComponentBehavior: Bound", - " import QtQuick", - "// import QtQuick.Controls", - "import \"../views\"", - }, - expected: []string{ - "pragma ComponentBehavior: Bound", - "", - "import QtQuick", - "", - "import \"../views\"", - }, - }, - { - input: []string{ - "import pyobjects", - " import QtQuick.Window", - " import io.github.whatever.MyTheme", - " import IO.github.whatever.MyTheme", - "", - "import QtQuick", - "import \"../views\"", - " import QtQuick.Layouts", - " import QtQuick.Controls.Material ", - "pragma ComponentBehavior: Bound", - "", - "", - "", - }, - expected: []string{ - "pragma ComponentBehavior: Bound", - "", - "import QtQuick", - "import QtQuick.Controls.Material", - "import QtQuick.Layouts", - "import QtQuick.Window", - "", - "import IO.github.whatever.MyTheme", - "import io.github.whatever.MyTheme", - "", - "import pyobjects", - "", - "import \"../views\"", - }, - }, - } { - actual, err := organizeQmlHeaderStatements(test.input) - if err != nil || !reflect.DeepEqual(actual, test.expected) { - t.Errorf(`organizeQmlHeaderStatements(%v) = %v, want %v, error %v`, test.input, actual, test.expected, err) - } - } - -} - -func TestIdentifyRelevantLines(t *testing.T) { - for _, test := range []struct { - input []string - expectedStartIdx int - expectedEndIdx int - }{ - { - input: []string{ - "import QtQuick", - "QtObject{", - }, - expectedStartIdx: 0, - expectedEndIdx: 0, - }, - { - input: []string{ - " import QtQuick", - "import QtQuick.Controls", - "QtObject {", - }, - expectedStartIdx: 0, - expectedEndIdx: 1, - }, - { - input: []string{ - "import QtQuick 2.0", - "import QtQuick.Controls", - "TextInput{", - }, - expectedStartIdx: 0, - expectedEndIdx: 1, - }, - { - input: []string{ - " import QtQuick", - " import QtQuick.Controls", - " ", - " ", - "QtObject {", - }, - expectedStartIdx: 0, - expectedEndIdx: 3, - }, - { - input: []string{ - " import QtQuick", - " // comment", - " import QtQuick.Controls", - " ", - " ", - " Loader {", - }, - expectedStartIdx: 0, - expectedEndIdx: 4, - }, - { - input: []string{ - "pragma Singleton", - "", - "import QtQuick", - "import QtQuick.Controls", - "", - "QtObject {", - }, - expectedStartIdx: 0, - expectedEndIdx: 4, - }, - { - input: []string{ - "", - "", - "import QtQuick", - "", - "// inline comment", - "import QtQuick.Controls", - "import MyModule", - "", - "", - "Item {", - }, - expectedStartIdx: 0, - expectedEndIdx: 8, - }, - { - input: []string{ - "// Header comment", - "", - "pragma Singleton", - "", - "", - "import QtQuick", - "", - "QtObject {", - }, - expectedStartIdx: 1, - expectedEndIdx: 6, - }, - { - input: []string{ - "/**", - " * MOCKED-FileCopyrightText: Jane Doe", - " *", - " * MOCKED-License-Identifier: MIT", - " */", - " ", - " ", - " import QtQuick", - " import QtQuick.Controls", - " ", - " ", - "QtObject {", - }, - expectedStartIdx: 5, - expectedEndIdx: 10, - }, - { - input: []string{ - "", - " // MOCKED-FileCopyrightText: Jane Doe", - " // ", - " // MOCKED-License-Identifier: MIT", - " ", - " ", - " import QtQuick", - " import QtQuick.Controls", - " ", - " ", - "QtObject {", - }, - expectedStartIdx: 4, - expectedEndIdx: 9, - }, - } { - start, end, err := identifyRelevantLines(test.input) - if err != nil || start != test.expectedStartIdx || end != test.expectedEndIdx { - t.Errorf(`identifyRelevantLines(%v) = [%d, %d), want [%d, %d), error %v`, - test.input, start, end, test.expectedStartIdx, test.expectedEndIdx, err) - } - } - -} From 8e7e3d1ea302438863ab18e648cdca25df7bde0a Mon Sep 17 00:00:00 2001 From: Elias Mueller Date: Fri, 24 Apr 2026 18:06:17 +0200 Subject: [PATCH 5/8] test: scaffold qml.Format with contract tests Adds an unimplemented Format([]byte) ([]byte, error) stub and a 64-subtest suite across 12 groups pinning down the behavior contract: classification, sorting, preamble, line endings, blank lines, comments, whitespace, passthrough, deduplication, errors, edge cases, and a kitchen-sink integration case. Idempotence is checked on every passing case. Tests default to Qt 6 style (no version) with a few cases kept versioned to cover both styles. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/qml/format.go | 82 +++ internal/qml/format_test.go | 1343 +++++++++++++++++++++++++++++++++++ 2 files changed, 1425 insertions(+) create mode 100644 internal/qml/format.go create mode 100644 internal/qml/format_test.go diff --git a/internal/qml/format.go b/internal/qml/format.go new file mode 100644 index 0000000..5e7a745 --- /dev/null +++ b/internal/qml/format.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Elias Mueller +// +// SPDX-License-Identifier: MIT + +package qml + +import "errors" + +// Format sorts, groups, and deduplicates QML imports in src, returning +// the formatted bytes. src is not modified. +// +// Line endings are detected from the input — the first \n, \r\n, or \r +// encountered wins — and that ending is used as the separator throughout +// the output. If the input contains other line-ending bytes later +// (genuinely mixed line endings), they remain as character content on +// their lines; for pragma and import lines, trailing-whitespace +// normalization will strip such stray bytes when they sit at line ends. +// +// A QML document is split into three parts: the preamble (everything +// before the first pragma or import, including any comment lines that +// sit immediately above it), the block (from the first to the last +// pragma/import, inclusive), and the body (everything after). The +// preamble and body are passed through untouched, with two exceptions: +// leading blank lines at the top of the file are stripped, and the +// blank-line separators around the block are normalized to exactly one +// blank line. This means license headers always stay put — whether or +// not the author put a blank line between the header and the imports, +// the header is preamble and the formatter supplies the blank line. +// +// Within the block, each pragma or import is classified into one of +// five categories and emitted in this fixed order: +// +// 1. pragma +// 2. qt — e.g. import QtQuick, import QtQuick.Controls, import Qt5Compat.* +// 3. library — dotted module path, e.g. import io.github.me +// 4. module — bare identifier, e.g. import MyModule +// 5. relative — quoted path, e.g. import "./components" +// +// Within each category, entries are sorted by normalized text in byte +// order — case-sensitive, so "A" < "a" per ASCII. Duplicates (by +// normalized text) are removed; the first occurrence wins and keeps +// its attached comments, subsequent duplicates are dropped along with +// any comments attached to them. +// +// Comments inside the block attach to the following pragma or import +// and travel with it through sorting. Consecutive comments stay +// together as a group. +// +// Blank lines inside the block use an all-or-nothing rule per category: +// if any blank line appears between imports of a category in the input, +// the output inserts a blank line between every adjacent pair of +// imports in that category. Otherwise, that category is emitted +// tightly packed. Structural separators — between preamble and block, +// between category groups, and between block and body — are always +// normalized to exactly one blank line; multiple blanks in these +// positions collapse to one. +// +// Pragma and import lines are emitted canonical: leading and trailing +// whitespace is stripped, and runs of whitespace between tokens are +// collapsed to single spaces. Whitespace inside quoted strings is +// preserved — the quoted path is treated as a single token, so +// `import "./my file.qml"` becomes `import "./my file.qml"`. +// A trailing comment on the same line as an import or pragma (e.g. +// `import QtQuick 2.15 // note`) is treated as part of that line's +// text: it is kept, it participates in sorting, and the same +// whitespace-collapse rule applies to it. To have a comment that +// travels independently, put it on its own line. +// +// Line comments (//) on their own line are emitted canonical, with +// leading whitespace stripped. Block-comment lines — those whose +// trimmed content starts with /*, *, or */ — preserve their leading +// whitespace so that multi-line block comment alignment survives. +// Whitespace-only lines count as blank. +// +// A file with no imports (and no pragmas) is valid input and is +// returned unchanged. +// +// Returns an error if a line inside the block cannot be classified as +// a pragma, import, comment, or blank line. +func Format(src []byte) ([]byte, error) { + return nil, errors.New("qml.Format: not implemented") +} diff --git a/internal/qml/format_test.go b/internal/qml/format_test.go new file mode 100644 index 0000000..75e6215 --- /dev/null +++ b/internal/qml/format_test.go @@ -0,0 +1,1343 @@ +// SPDX-FileCopyrightText: Elias Mueller +// +// SPDX-License-Identifier: MIT + +package qml + +import ( + "strings" + "testing" +) + +type formatCase struct { + name string + lineEnding string + input []string + expected []string + wantErr bool +} + +func runFormatCases(t *testing.T, cases []formatCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.lineEnding == "" { + t.Fatalf("test case %q has empty lineEnding; set it explicitly", tc.name) + } + input := strings.Join(tc.input, tc.lineEnding) + tc.lineEnding + + got, err := Format([]byte(input)) + + if tc.wantErr { + if err == nil { + t.Errorf("expected error, got nil (output: %q)", got) + } + return + } + + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + + expected := strings.Join(tc.expected, tc.lineEnding) + tc.lineEnding + if string(got) != expected { + t.Errorf("Format output mismatch\nwant:\n%q\ngot:\n%q", expected, got) + return + } + + got2, err := Format(got) + if err != nil { + t.Fatalf("second Format returned error: %v", err) + } + if string(got2) != string(got) { + t.Errorf("Format is not idempotent\nfirst:\n%q\nsecond:\n%q", got, got2) + } + }) + } +} + +func TestFormatClassification(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "single qt import passes through unchanged", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "single library import passes through unchanged", + lineEnding: "\n", + input: []string{ + "import io.github.me 1.0", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import io.github.me 1.0", + "", + "Rectangle {", + "}", + }, + }, + { + name: "single module import passes through unchanged", + lineEnding: "\n", + input: []string{ + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import MyModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "single relative import with double quotes passes through unchanged", + lineEnding: "\n", + input: []string{ + `import "./components"`, + "", + "Rectangle {", + "}", + }, + expected: []string{ + `import "./components"`, + "", + "Rectangle {", + "}", + }, + }, + { + name: "single relative import with single quotes passes through unchanged", + lineEnding: "\n", + input: []string{ + `import './components'`, + "", + "Rectangle {", + "}", + }, + expected: []string{ + `import './components'`, + "", + "Rectangle {", + "}", + }, + }, + { + name: "various qt import forms classify and sort correctly", + lineEnding: "\n", + input: []string{ + "import QtQuick.Controls 2.15", + "import Qt5Compat.GraphicalEffects", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import Qt5Compat.GraphicalEffects", + "import QtQuick", + "import QtQuick.Controls 2.15", + "", + "Rectangle {", + "}", + }, + }, + { + name: "qt import with dot path but no version classifies correctly", + lineEnding: "\n", + input: []string{ + "import QtQuick.Controls", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick.Controls", + "", + "Rectangle {", + "}", + }, + }, + { + name: "library import without version classifies correctly", + lineEnding: "\n", + input: []string{ + "import io.github.me", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import io.github.me", + "", + "Rectangle {", + "}", + }, + }, + { + name: "module import with version classifies correctly", + lineEnding: "\n", + input: []string{ + "import MyModule 1.0", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import MyModule 1.0", + "", + "Rectangle {", + "}", + }, + }, + { + name: "relative import with parent directory path classifies correctly", + lineEnding: "\n", + input: []string{ + `import "../lib/foo.qml"`, + "", + "Rectangle {", + "}", + }, + expected: []string{ + `import "../lib/foo.qml"`, + "", + "Rectangle {", + "}", + }, + }, + }) +} + +func TestFormatSorting(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "two qt imports are sorted alphabetically", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "import QtQml", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQml", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "mixed categories are grouped and ordered", + lineEnding: "\n", + input: []string{ + `import "./components"`, + "import MyModule", + "import io.github.me", + "import QtQuick", + "pragma Singleton", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "pragma Singleton", + "", + "import QtQuick", + "", + "import io.github.me", + "", + "import MyModule", + "", + `import "./components"`, + "", + "Rectangle {", + "}", + }, + }, + { + name: "pragmas are sorted alphabetically", + lineEnding: "\n", + input: []string{ + "pragma Singleton", + "pragma ComponentBehavior: Bound", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "pragma ComponentBehavior: Bound", + "pragma Singleton", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "qt import with alias sorts after the unaliased version", + lineEnding: "\n", + input: []string{ + "import QtQuick as QQ", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "import QtQuick as QQ", + "", + "Rectangle {", + "}", + }, + }, + { + name: "pragma appearing after imports is reordered to the top", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "pragma Singleton", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "pragma Singleton", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "sort is case-sensitive with uppercase before lowercase", + lineEnding: "\n", + input: []string{ + "import aaa", + "import Aaa", + "import AAA", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import AAA", + "import Aaa", + "import aaa", + "", + "Rectangle {", + "}", + }, + }, + }) +} + +func TestFormatPreamble(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "preamble with a single line comment is preserved", + lineEnding: "\n", + input: []string{ + "// SPDX-License-Identifier: MIT", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// SPDX-License-Identifier: MIT", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "preamble with multiple line comments is preserved", + lineEnding: "\n", + input: []string{ + "// SPDX-FileCopyrightText: Elias Mueller", + "//", + "// SPDX-License-Identifier: MIT", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// SPDX-FileCopyrightText: Elias Mueller", + "//", + "// SPDX-License-Identifier: MIT", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "preamble with a single-line block comment is preserved", + lineEnding: "\n", + input: []string{ + "/* SPDX-License-Identifier: MIT */", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "/* SPDX-License-Identifier: MIT */", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "preamble with a multi-line block comment is preserved", + lineEnding: "\n", + input: []string{ + "/*", + " * SPDX-FileCopyrightText: Elias Mueller", + " *", + " * SPDX-License-Identifier: MIT", + " */", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "/*", + " * SPDX-FileCopyrightText: Elias Mueller", + " *", + " * SPDX-License-Identifier: MIT", + " */", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "leading blank lines at the top of the file are stripped (with preamble)", + lineEnding: "\n", + input: []string{ + "", + "", + "// SPDX-License-Identifier: MIT", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// SPDX-License-Identifier: MIT", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "leading blank lines at the top of the file are stripped (no preamble)", + lineEnding: "\n", + input: []string{ + "", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "blank lines inside the preamble are preserved while trailing blanks collapse", + lineEnding: "\n", + input: []string{ + "// Copyright notice", + "", + "", + "// License text", + "", + "", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// Copyright notice", + "", + "", + "// License text", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "preamble with mixed line and block comments is preserved", + lineEnding: "\n", + input: []string{ + "/* License block */", + "// Additional copyright notice", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "/* License block */", + "// Additional copyright notice", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "license header without a blank line before the imports stays in the preamble", + lineEnding: "\n", + input: []string{ + "// SPDX-License-Identifier: MIT", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// SPDX-License-Identifier: MIT", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + }) +} + +func TestFormatLineEndings(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "CRLF line endings are preserved", + lineEnding: "\r\n", + input: []string{ + "import QtQuick", + "import QtQml", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQml", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "CR line endings are preserved", + lineEnding: "\r", + input: []string{ + "import QtQuick", + "import QtQml", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQml", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + }) +} + +func TestFormatBlankLines(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "blank line is inserted between imports and body when missing", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "multiple blank lines between preamble and imports collapse to one", + lineEnding: "\n", + input: []string{ + "// SPDX-License-Identifier: MIT", + "", + "", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// SPDX-License-Identifier: MIT", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "multiple blank lines between imports and body collapse to one", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "", + "", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "multiple blank lines between category groups collapse to one", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "", + "", + "", + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "import MyModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "multiple consecutive blanks between same-category imports collapse to one", + lineEnding: "\n", + input: []string{ + "import YourModule", + "", + "", + "", + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import MyModule", + "", + "import YourModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "single blank between same-category imports is preserved after sort", + lineEnding: "\n", + input: []string{ + "import YourModule", + "", + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import MyModule", + "", + "import YourModule", + "", + "Rectangle {", + "}", + }, + }, + }) +} + +func TestFormatComments(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "comments inside import block travel with next import through sort", + lineEnding: "\n", + input: []string{ + "// note about YourModule", + "import YourModule", + "", + "// note about MyModule", + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// note about MyModule", + "import MyModule", + "", + "// note about YourModule", + "import YourModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "multiple consecutive comments attach to the following import", + lineEnding: "\n", + input: []string{ + "// first comment for ZZZ", + "// second comment for ZZZ", + "import ZZZ", + "// comment for AAA", + "import AAA", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// comment for AAA", + "import AAA", + "// first comment for ZZZ", + "// second comment for ZZZ", + "import ZZZ", + "", + "Rectangle {", + "}", + }, + }, + { + name: "comment after last import is treated as body and left untouched", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "// body-level comment", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "// body-level comment", + "Rectangle {", + "}", + }, + }, + { + name: "indented line comment inside the block has its indentation stripped", + lineEnding: "\n", + input: []string{ + "import YourModule", + " // indented note about MyModule", + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// indented note about MyModule", + "import MyModule", + "import YourModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "block comment inside the block preserves its alignment and travels with its import", + lineEnding: "\n", + input: []string{ + "import YourModule", + "/*", + " * note about MyModule", + " */", + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "/*", + " * note about MyModule", + " */", + "import MyModule", + "import YourModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "single-line block comment inside the block attaches to the following import", + lineEnding: "\n", + input: []string{ + "import YourModule", + "/* note about MyModule */", + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "/* note about MyModule */", + "import MyModule", + "import YourModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "comment immediately before the first import is preamble and does not move with sort", + lineEnding: "\n", + input: []string{ + "// leading comment", + "import YourModule", + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// leading comment", + "", + "import MyModule", + "import YourModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "line that looks like an import inside a comment remains a comment", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "// import QtQml", + "import QtQml", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// import QtQml", + "import QtQml", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "comment between pragmas travels with the following pragma through sort", + lineEnding: "\n", + input: []string{ + "pragma Singleton", + "// note about ComponentBehavior", + "pragma ComponentBehavior: Bound", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// note about ComponentBehavior", + "pragma ComponentBehavior: Bound", + "pragma Singleton", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "trailing comment on import line becomes part of the line text", + lineEnding: "\n", + input: []string{ + "import QtQuick // zz comment", + "import QtQml // aa comment", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQml // aa comment", + "import QtQuick // zz comment", + "", + "Rectangle {", + "}", + }, + }, + }) +} + +func TestFormatWhitespace(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "leading whitespace after import keyword is normalized", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "indented pragma is normalized to no leading whitespace", + lineEnding: "\n", + input: []string{ + " pragma Singleton", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "pragma Singleton", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "indented imports are normalized regardless of spaces or tabs", + lineEnding: "\n", + input: []string{ + " import QtQuick", + "\timport QtQml", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQml", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "whitespace-only lines count as blank", + lineEnding: "\n", + input: []string{ + "import YourModule", + " ", + "\t", + "import MyModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import MyModule", + "", + "import YourModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "trailing whitespace on import lines is stripped", + lineEnding: "\n", + input: []string{ + "import QtQuick ", + "import QtQml\t", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQml", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "internal whitespace inside import lines is normalized to single spaces", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "import\tQtQml\tas\tQQ", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQml as QQ", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "whitespace inside quoted relative-import paths is preserved", + lineEnding: "\n", + input: []string{ + `import "./my file.qml"`, + "", + "Rectangle {", + "}", + }, + expected: []string{ + `import "./my file.qml"`, + "", + "Rectangle {", + "}", + }, + }, + }) +} + +func TestFormatPassthrough(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "file with no imports passes through unchanged", + lineEnding: "\n", + input: []string{ + "Rectangle {", + " width: 100", + " height: 100", + "}", + }, + expected: []string{ + "Rectangle {", + " width: 100", + " height: 100", + "}", + }, + }, + { + name: "file with only pragmas and no imports is handled", + lineEnding: "\n", + input: []string{ + "pragma Singleton", + "pragma ComponentBehavior: Bound", + "", + "QtObject {", + "}", + }, + expected: []string{ + "pragma ComponentBehavior: Bound", + "pragma Singleton", + "", + "QtObject {", + "}", + }, + }, + }) +} + +func TestFormatDeduplication(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "duplicate imports are deduplicated", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "duplicate pragmas are deduplicated", + lineEnding: "\n", + input: []string{ + "pragma Singleton", + "pragma Singleton", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "pragma Singleton", + "", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "duplicates are detected after whitespace normalization", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "import QtQuick 2.15", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + { + name: "when duplicates have different comments the first occurrence's comment wins", + lineEnding: "\n", + input: []string{ + "// first comment", + "import QtQuick", + "// second comment", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "// first comment", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + }, + }) +} + +func TestFormatErrors(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "import with digit-prefixed module name returns an error", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "import 123invalid", + "", + "Rectangle {", + "}", + }, + wantErr: true, + }, + { + name: "import with dash in module name returns an error", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "import foo-bar", + "", + "Rectangle {", + "}", + }, + wantErr: true, + }, + { + name: "import with special character prefix returns an error", + lineEnding: "\n", + input: []string{ + "import QtQuick", + "import @foo", + "", + "Rectangle {", + "}", + }, + wantErr: true, + }, + }) +} + +func TestFormatEdgeCases(t *testing.T) { + t.Run("empty input returns empty output without error", func(t *testing.T) { + got, err := Format([]byte{}) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty output, got %q", got) + } + }) + + t.Run("whitespace-only input returns empty output", func(t *testing.T) { + got, err := Format([]byte("\n\n\n")) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty output, got %q", got) + } + }) + + t.Run("input without trailing newline preserves that property", func(t *testing.T) { + input := "import QtQuick\nRectangle {}" + expected := "import QtQuick\n\nRectangle {}" + got, err := Format([]byte(input)) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + if string(got) != expected { + t.Errorf("Format output mismatch\nwant: %q\ngot: %q", expected, got) + } + }) + + t.Run("mixed line endings use the first-detected ending as the separator", func(t *testing.T) { + // Input starts with \n (first-detected), but the second import uses \r\n. + // After split by \n, the trailing \r is a trimmable whitespace byte that + // gets stripped by the import-line normalization rule. + input := "import QtQuick\nimport QtQml\r\n" + expected := "import QtQml\nimport QtQuick\n" + got, err := Format([]byte(input)) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + if string(got) != expected { + t.Errorf("Format output mismatch\nwant: %q\ngot: %q", expected, got) + } + }) +} + +func TestFormatIntegration(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "realistic file exercises preamble, all categories, comments, dedup, and whitespace normalization", + lineEnding: "\n", + input: []string{ + "/*", + " * SPDX-FileCopyrightText: Elias Mueller", + " *", + " * SPDX-License-Identifier: MIT", + " */", + "", + "pragma Singleton", + "pragma ComponentBehavior: Bound", + "import QtQuick 2.15", + `import "./components"`, + "// note about MyLib", + "import io.github.mylib", + "import MyModule", + "import QtQml", + "import io.github.mylib", + "", + "Rectangle {", + " width: 100", + " // internal comment", + " height: 100", + "}", + }, + expected: []string{ + "/*", + " * SPDX-FileCopyrightText: Elias Mueller", + " *", + " * SPDX-License-Identifier: MIT", + " */", + "", + "pragma ComponentBehavior: Bound", + "pragma Singleton", + "", + "import QtQml", + "import QtQuick", + "", + "// note about MyLib", + "import io.github.mylib", + "", + "import MyModule", + "", + `import "./components"`, + "", + "Rectangle {", + " width: 100", + " // internal comment", + " height: 100", + "}", + }, + }, + }) +} From 48e8592d55b8f1088dc286439201ee1744a237bc Mon Sep 17 00:00:00 2001 From: Elias Mueller Date: Fri, 24 Apr 2026 18:53:47 +0200 Subject: [PATCH 6/8] test: add Options for classification-prefix customization qml.Format now takes Options{LibraryPrefixes, ModulePrefixes}; prefixes let callers promote bare names to library or demote dotted names to module, with precedence ahead of the default heuristics. Adds contract tests for the override behavior and for 12 validation rules (empty, leading dot, Qt/qt start, "pragma", overlaps within and across lists). Prefixes are trimmed before validation and use. Stub still returns "not implemented". Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/qml/format.go | 68 +++++- internal/qml/format_test.go | 444 +++++++++++++++++++++++++++++++++++- 2 files changed, 498 insertions(+), 14 deletions(-) diff --git a/internal/qml/format.go b/internal/qml/format.go index 5e7a745..15f5e98 100644 --- a/internal/qml/format.go +++ b/internal/qml/format.go @@ -6,8 +6,53 @@ package qml import "errors" +// Options configure Format's classifier. The zero value applies only the +// default heuristics. +// +// LibraryPrefixes and ModulePrefixes let the caller override the default +// classifier for specific imports. When an import's normalized text +// starts with any string in LibraryPrefixes, it is classified as library +// regardless of whether its name contains a dot. Similarly for +// ModulePrefixes. See Format for the full precedence rules. +// +// A prefix is a literal byte-level string match against the import text +// (the content after "import " with leading/trailing whitespace stripped +// and internal whitespace collapsed). Include a trailing "." to match +// only dotted subpaths (e.g. "MyCorp." matches "MyCorp.Foo" but not +// "MyCorpX"). +// +// Prefixes are trimmed of leading and trailing whitespace before +// validation and matching, so " MyLib " is treated as "MyLib". This +// trim happens before all validation rules below, which means an +// all-whitespace prefix is rejected as empty and a prefix like +// " Qt " is rejected for starting with "Qt". +// +// Format validates the Options at entry and returns an error if any of +// the following rules is violated; every error names the specific +// offending prefix(es): +// +// - No prefix may be the empty string (after trimming). +// - No prefix may start with "." (e.g. ".foo" is rejected). +// - No prefix may start with "Qt" or "qt" — Qt imports are their own +// category and are not overridable via prefix. +// - No prefix may equal "pragma" (pragma is a separate category, +// not overridable via prefix). +// - Within a single list, no two prefixes may overlap — meaning one +// is a prefix of the other, or the two are identical. Duplicates +// are reported as "duplicate"; non-identical overlaps (e.g. "Foo" +// and "Foo.Bar") are reported as "overlapping". +// - Across the two lists, the same rule applies: a prefix in +// LibraryPrefixes and a prefix in ModulePrefixes may not overlap +// with each other. This catches both exact matches (the prefix +// appears in both) and one-is-a-prefix-of-the-other cases. +type Options struct { + LibraryPrefixes []string + ModulePrefixes []string +} + // Format sorts, groups, and deduplicates QML imports in src, returning -// the formatted bytes. src is not modified. +// the formatted bytes. src is not modified. opts configures the +// classifier; Options{} preserves the default behavior. // // Line endings are detected from the input — the first \n, \r\n, or \r // encountered wins — and that ending is used as the separator throughout @@ -36,6 +81,22 @@ import "errors" // 4. module — bare identifier, e.g. import MyModule // 5. relative — quoted path, e.g. import "./components" // +// Classification precedence runs top-to-bottom; the first rule that +// matches wins: +// +// 1. pragma keyword → pragma +// 2. Qt[A-Z0-9.] pattern → qt +// 3. opts.LibraryPrefixes match → library (override) +// 4. opts.ModulePrefixes match → module (override) +// 5. text contains a dot → library (default heuristic) +// 6. text is a bare identifier → module (default heuristic) +// 7. text starts with " or ' → relative +// 8. none of the above → error +// +// User-configured prefixes are checked before the default dot/bare +// heuristics, so a caller can both promote (make a bare name count as +// library) and demote (make a dotted name count as module). +// // Within each category, entries are sorted by normalized text in byte // order — case-sensitive, so "A" < "a" per ASCII. Duplicates (by // normalized text) are removed; the first occurrence wins and keeps @@ -75,8 +136,9 @@ import "errors" // A file with no imports (and no pragmas) is valid input and is // returned unchanged. // -// Returns an error if a line inside the block cannot be classified as +// Returns an error if opts fails validation (see Options for the full +// list of rules) or if a line inside the block cannot be classified as // a pragma, import, comment, or blank line. -func Format(src []byte) ([]byte, error) { +func Format(src []byte, opts Options) ([]byte, error) { return nil, errors.New("qml.Format: not implemented") } diff --git a/internal/qml/format_test.go b/internal/qml/format_test.go index 75e6215..7a88fe9 100644 --- a/internal/qml/format_test.go +++ b/internal/qml/format_test.go @@ -10,11 +10,13 @@ import ( ) type formatCase struct { - name string - lineEnding string - input []string - expected []string - wantErr bool + name string + lineEnding string + input []string + expected []string + options Options + wantErr bool + wantErrContains string } func runFormatCases(t *testing.T, cases []formatCase) { @@ -26,11 +28,15 @@ func runFormatCases(t *testing.T, cases []formatCase) { } input := strings.Join(tc.input, tc.lineEnding) + tc.lineEnding - got, err := Format([]byte(input)) + got, err := Format([]byte(input), tc.options) if tc.wantErr { if err == nil { t.Errorf("expected error, got nil (output: %q)", got) + return + } + if tc.wantErrContains != "" && !strings.Contains(err.Error(), tc.wantErrContains) { + t.Errorf("error message %q does not contain %q", err.Error(), tc.wantErrContains) } return } @@ -45,7 +51,7 @@ func runFormatCases(t *testing.T, cases []formatCase) { return } - got2, err := Format(got) + got2, err := Format(got, tc.options) if err != nil { t.Fatalf("second Format returned error: %v", err) } @@ -1237,7 +1243,7 @@ func TestFormatErrors(t *testing.T) { func TestFormatEdgeCases(t *testing.T) { t.Run("empty input returns empty output without error", func(t *testing.T) { - got, err := Format([]byte{}) + got, err := Format([]byte{}, Options{}) if err != nil { t.Fatalf("Format returned error: %v", err) } @@ -1247,7 +1253,7 @@ func TestFormatEdgeCases(t *testing.T) { }) t.Run("whitespace-only input returns empty output", func(t *testing.T) { - got, err := Format([]byte("\n\n\n")) + got, err := Format([]byte("\n\n\n"), Options{}) if err != nil { t.Fatalf("Format returned error: %v", err) } @@ -1259,7 +1265,7 @@ func TestFormatEdgeCases(t *testing.T) { t.Run("input without trailing newline preserves that property", func(t *testing.T) { input := "import QtQuick\nRectangle {}" expected := "import QtQuick\n\nRectangle {}" - got, err := Format([]byte(input)) + got, err := Format([]byte(input), Options{}) if err != nil { t.Fatalf("Format returned error: %v", err) } @@ -1274,7 +1280,7 @@ func TestFormatEdgeCases(t *testing.T) { // gets stripped by the import-line normalization rule. input := "import QtQuick\nimport QtQml\r\n" expected := "import QtQml\nimport QtQuick\n" - got, err := Format([]byte(input)) + got, err := Format([]byte(input), Options{}) if err != nil { t.Fatalf("Format returned error: %v", err) } @@ -1341,3 +1347,419 @@ func TestFormatIntegration(t *testing.T) { }, }) } + +func TestFormatCustomPrefixes(t *testing.T) { + runFormatCases(t, []formatCase{ + { + name: "library prefix promotes a bare identifier into the library group", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"MyLib"}}, + input: []string{ + "import MyLib", + "import QtQuick", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "import MyLib", + "", + "Rectangle {", + "}", + }, + }, + { + name: "module prefix demotes a dotted name into the module group", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{"MyCorp."}}, + input: []string{ + "import MyCorp.Foo", + "import io.github.other", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import io.github.other", + "", + "import MyCorp.Foo", + "", + "Rectangle {", + "}", + }, + }, + { + name: "trailing dot in the prefix creates a boundary that does not match siblings", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{"MyCorp."}}, + input: []string{ + "import MyCorp.Foo", + "import MyCorpExternal.Bar", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import MyCorpExternal.Bar", + "", + "import MyCorp.Foo", + "", + "Rectangle {", + "}", + }, + }, + { + name: "library and module prefixes combine across a mixed input", + lineEnding: "\n", + options: Options{ + LibraryPrefixes: []string{"MyLib"}, + ModulePrefixes: []string{"MyCorp."}, + }, + input: []string{ + "import MyLib", + "import MyCorp.Foo", + "import QtQuick", + "import io.github.other", + "import PlainModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "import MyLib", + "import io.github.other", + "", + "import MyCorp.Foo", + "import PlainModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "multiple prefixes of the same kind are all honored", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"Alpha", "Beta"}}, + input: []string{ + "import Alpha", + "import Beta", + "import Gamma", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import Alpha", + "import Beta", + "", + "import Gamma", + "", + "Rectangle {", + "}", + }, + }, + { + name: "user prefix does not change classification of unrelated imports", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{"MyCorp."}}, + input: []string{ + "import QtQuick", + "import io.github.other", + "import PlainModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "import io.github.other", + "", + "import PlainModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "user prefix matches imports with a trailing '//' comment", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"MyLib"}}, + input: []string{ + "import MyLib // project internal library", + "import PlainModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import MyLib // project internal library", + "", + "import PlainModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "leading and trailing whitespace in prefixes is trimmed before use", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{" MyLib "}}, + input: []string{ + "import MyLib", + "import PlainModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import MyLib", + "", + "import PlainModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "user prefix matches against whitespace-normalized import text", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"MyLib 1.0"}}, + input: []string{ + "import MyLib 1.0", + "import OtherModule", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import MyLib 1.0", + "", + "import OtherModule", + "", + "Rectangle {", + "}", + }, + }, + { + name: "prefixes combine with sort, dedup, and within-category blanks", + lineEnding: "\n", + options: Options{ + LibraryPrefixes: []string{"MyLib"}, + ModulePrefixes: []string{"MyCorp."}, + }, + input: []string{ + "import QtQuick", + "import MyCorp.Alpha", + "", + "import MyCorp.Beta", + "import MyLib", + "import MyLib", + "import io.github.other", + "", + "Rectangle {", + "}", + }, + expected: []string{ + "import QtQuick", + "", + "import MyLib", + "import io.github.other", + "", + "import MyCorp.Alpha", + "", + "import MyCorp.Beta", + "", + "Rectangle {", + "}", + }, + }, + }) +} + +func TestFormatInvalidOptions(t *testing.T) { + // Minimal input used by every case — the error must come from the + // options, not the document content. + minimalInput := []string{ + "import QtQuick", + "", + "Rectangle {", + "}", + } + + runFormatCases(t, []formatCase{ + { + name: "empty prefix in LibraryPrefixes is rejected", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{""}}, + input: minimalInput, + wantErr: true, + wantErrContains: "empty prefix", + }, + { + name: "empty prefix in ModulePrefixes is rejected", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{""}}, + input: minimalInput, + wantErr: true, + wantErrContains: "empty prefix", + }, + { + name: "prefix starting with '.' in LibraryPrefixes is rejected", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{".foo"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `".foo"`, + }, + { + name: "prefix starting with '.' in ModulePrefixes is rejected", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{".bar"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `".bar"`, + }, + { + name: "prefix equal to 'pragma' in LibraryPrefixes is rejected", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"pragma"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"pragma"`, + }, + { + name: "prefix equal to 'pragma' in ModulePrefixes is rejected", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{"pragma"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"pragma"`, + }, + { + name: "duplicate prefix in LibraryPrefixes is rejected and names the prefix", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"Foo", "Foo"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"Foo"`, + }, + { + name: "duplicate prefix in ModulePrefixes is rejected and names the prefix", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{"Bar.", "Bar."}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"Bar."`, + }, + { + name: "prefix-of-prefix overlap within LibraryPrefixes is rejected and names both", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"Foo", "Foo.Bar"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"Foo.Bar"`, + }, + { + name: "prefix-of-prefix overlap within a list is detected regardless of order (longer first)", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"Foo.Bar", "Foo"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"Foo.Bar"`, + }, + { + name: "prefix-of-prefix overlap within ModulePrefixes is rejected and names both", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{"Foo", "Foo.Bar"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"Foo.Bar"`, + }, + { + name: "same prefix in both LibraryPrefixes and ModulePrefixes is rejected and names the prefix", + lineEnding: "\n", + options: Options{ + LibraryPrefixes: []string{"Foo."}, + ModulePrefixes: []string{"Foo."}, + }, + input: minimalInput, + wantErr: true, + wantErrContains: `"Foo."`, + }, + { + name: "prefix-of-prefix overlap across lists is rejected and names both", + lineEnding: "\n", + options: Options{ + LibraryPrefixes: []string{"Foo"}, + ModulePrefixes: []string{"Foo.Bar"}, + }, + input: minimalInput, + wantErr: true, + wantErrContains: `"Foo.Bar"`, + }, + { + name: "prefix-of-prefix overlap across lists is detected with the shorter prefix in the other list", + lineEnding: "\n", + options: Options{ + LibraryPrefixes: []string{"Foo.Bar"}, + ModulePrefixes: []string{"Foo"}, + }, + input: minimalInput, + wantErr: true, + wantErrContains: `"Foo.Bar"`, + }, + { + name: "all-whitespace prefix is trimmed to empty and rejected", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{" "}}, + input: minimalInput, + wantErr: true, + wantErrContains: "empty prefix", + }, + { + name: "prefix starting with 'Qt' in LibraryPrefixes is rejected", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"QtCustom"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"QtCustom"`, + }, + { + name: "prefix starting with 'Qt' in ModulePrefixes is rejected", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{"QtCustom"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"QtCustom"`, + }, + { + name: "prefix starting with 'qt' in LibraryPrefixes is rejected", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{"qtcustom"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"qtcustom"`, + }, + { + name: "prefix starting with 'qt' in ModulePrefixes is rejected", + lineEnding: "\n", + options: Options{ModulePrefixes: []string{"qtcustom"}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"qtcustom"`, + }, + { + name: "prefix with surrounding whitespace is trimmed before the Qt-start check", + lineEnding: "\n", + options: Options{LibraryPrefixes: []string{" QtCustom "}}, + input: minimalInput, + wantErr: true, + wantErrContains: `"QtCustom"`, + }, + }) +} From c9b6824d25dfc80bbe769cf2d630d703d81cdf7a Mon Sep 17 00:00:00 2001 From: Elias Mueller Date: Fri, 24 Apr 2026 18:55:15 +0200 Subject: [PATCH 7/8] docs: document Options and prefix-based classification Updates the CLI and internal-API design docs to reflect the Options struct added in the previous commit: new --library-prefix and --module-prefix flags with precedence, trim, and validation rules (empty, leading dot, Qt/qt start, "pragma", within-list and cross- list overlaps). INTERNAL_API.md now threads qml.Options through the fs helper signatures and shows it in the per-mode composition table. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/devel/CLI.md | 38 ++++++++++++--- docs/devel/INTERNAL_API.md | 99 +++++++++++++++++++++++++------------- 2 files changed, 97 insertions(+), 40 deletions(-) diff --git a/docs/devel/CLI.md b/docs/devel/CLI.md index 2321d6b..99aad06 100644 --- a/docs/devel/CLI.md +++ b/docs/devel/CLI.md @@ -34,13 +34,15 @@ Each `` is a file or directory. Directories are walked recursively. ### Flags -| Flag | Short | Purpose | -| ----------- | ----- | ------------------------------------------------------------------------------------ | -| `--check` | `-c` | Don't write. Print paths that would change to stdout, one per line. Exit 1 if any. | -| `--stdout` | | Don't write. Print formatted content to stdout. Single file only (see restrictions). | -| `--stdin` | | Read from stdin, write to stdout. Mutually exclusive with positional paths. | -| `--version` | | Print version, exit 0. | -| `--help` | `-h` | Print usage, exit 0. | +| Flag | Short | Purpose | +| ------------------ | ----- | -------------------------------------------------------------------------------------------------------------- | +| `--check` | `-c` | Don't write. Print paths that would change to stdout, one per line. Exit 1 if any. | +| `--stdout` | | Don't write. Print formatted content to stdout. Single file only (see restrictions). | +| `--stdin` | | Read from stdin, write to stdout. Mutually exclusive with positional paths. | +| `--library-prefix` | | Additional prefix to classify as a library import. Repeatable. Overrides the default dot-heuristic. | +| `--module-prefix` | | Additional prefix to classify as a module import. Repeatable. Overrides the default bare-identifier heuristic. | +| `--version` | | Print version, exit 0. | +| `--help` | `-h` | Print usage, exit 0. | ### Flag combinations @@ -51,6 +53,28 @@ Each `` is a file or directory. Directories are walked recursively. - `--stdin` combined with `--stdout`: redundant but allowed — stdin already goes to stdout. - `--stdout` requires exactly one input that is a file. Passing a directory, or more than one path, with `--stdout` is a usage error (exit 2). Rationale: no unambiguous way to concatenate multiple files' output. +- Prefix values are trimmed of leading and trailing whitespace before validation and matching (`--library-prefix=" Foo "` is treated as `Foo`). +- Prefix values are validated (usage error, exit 2) if any of the following holds: + - an empty prefix is given (or one that is all whitespace, which trims to empty), + - a prefix starts with `.`, + - a prefix starts with `Qt` or `qt`, + - a prefix equals `pragma`, + - two prefixes overlap — either identical or one-is-a-prefix-of-the-other — whether within one flag or across `--library-prefix` and `--module-prefix`. + The error names the specific prefix(es) involved. + +### Classification override (`--library-prefix` / `--module-prefix`) + +Both flags are repeatable. Each occurrence adds one literal prefix that is matched byte-for-byte against the normalized import text (the content after `import ` with whitespace trimmed). Include a trailing `.` in the prefix to create a boundary: `--module-prefix=MyCorp.` matches `import MyCorp.Foo` but not `import MyCorpExternal`. + +User prefixes take precedence over the default dot/bare heuristic, so they can both promote (make a bare name count as library) and demote (make a dotted name count as module). The category precedence order is: pragma, qt, `--library-prefix`, `--module-prefix`, default library (dotted), default module (bare), relative. + +Example: + +``` +qmlimportsort --module-prefix=AppComponents. --library-prefix=MyLib src/ +``` + +Anything starting with `AppComponents.` is classified as a module; `MyLib` (bare) is classified as a library. ### Directory walking diff --git a/docs/devel/INTERNAL_API.md b/docs/devel/INTERNAL_API.md index b5f6232..0580db4 100644 --- a/docs/devel/INTERNAL_API.md +++ b/docs/devel/INTERNAL_API.md @@ -28,31 +28,64 @@ ______________________________________________________________________ ### Package `internal/qml` -Exports exactly one function. All parsing, classification, and reassembly logic stays unexported. +Exports one function and one struct. All parsing, classification, and reassembly logic stays unexported. ```go -// Format sorts and groups QML imports in src, returning the formatted bytes. -// The input's line endings (\n, \r\n, or \r) are detected and preserved -// in the output. src is not modified. -// -// Comments inside the import block are preserved: each comment line is -// attached to the following import and travels with it through sorting. -// -// A file with no imports (and no pragmas) is a valid input and is -// returned unchanged. -// -// Returns an error if the input cannot be parsed — specifically, if a -// line inside the pragma/import block cannot be classified as a pragma, -// import, blank line, or comment. -func Format(src []byte) ([]byte, error) +type Options struct { + LibraryPrefixes []string + ModulePrefixes []string +} + +func Format(src []byte, opts Options) ([]byte, error) ``` +`Options{}` (zero value) applies only the default heuristics. `LibraryPrefixes` and `ModulePrefixes` are literal prefix overrides on the default classifier — caller-supplied strings that force a specific category when the import text starts with any of them. Include a trailing `.` to create a boundary (e.g. `MyCorp.` matches `MyCorp.Foo` but not `MyCorpX`). + +Prefixes are trimmed of leading and trailing whitespace before validation and matching — `" MyLib "` is treated as `"MyLib"`. Trimming happens before every rule below, so an all-whitespace prefix is rejected as empty. + +Format validates the Options at entry and returns an error (naming the offending prefix) if any rule is violated: + +- No prefix may be the empty string (after trimming). +- No prefix may start with `.`. +- No prefix may start with `Qt` or `qt` (Qt imports are their own category and not overridable via prefix). +- No prefix may equal `pragma` (pragma is a separate category, not overridable). +- No two prefixes may **overlap** — i.e. be identical, or one a prefix of the other — whether within the same list or across `LibraryPrefixes` and `ModulePrefixes`. Identical duplicates are reported as `"duplicate"`; non-identical overlaps as `"overlapping"`. + +These rules are enforced uniformly: a conflict between two entries in the same list and a conflict across the two lists are both errors with the same reasoning — "either prefix could match the same import, and there's no sensible default for which wins." + +**Classification precedence**: pragma → qt → user `LibraryPrefixes` → user `ModulePrefixes` → default library (dotted) → default module (bare) → relative → error. User prefixes are checked *before* the default dot/bare heuristics so callers can both promote (bare → library) and demote (dotted → module) symmetrically. + +The full behavior contract — block boundaries, category ordering, sorting, deduplication, comment and blank-line handling, whitespace normalization, error cases — lives in the godoc on `Format` in [`internal/qml/format.go`](../../internal/qml/format.go). The prose below covers the _implementation_ approach, not the contract. + The package will need unexported helpers for (a) detecting the input's line ending, (b) locating the pragma/import block within the document, (c) classifying each line into one of the import categories (pragma, Qt, library, module, relative), and (d) reassembling the categories back into output bytes. The exact shape of these helpers is an implementation detail and not part of the API contract. -**Implementation approach: line tokenizer.** A single pass over the import block produces a slice of tokens of the form `{kind, text, leadingComments []string}`, where `kind` is one of the five categories. Sorting and grouping operate on that slice, and output is reassembled by walking the sorted tokens. +**Implementation approach: line tokenizer.** A single pass over the import block produces a slice of tokens of the form `{kind, text, leadingComments []string, leadingBlank bool}`, where `kind` is one of the five categories. Sorting and grouping operate on that slice, and output is reassembled by walking the sorted tokens. Comments inside the block are preserved: while scanning, contiguous comment lines accumulate into a buffer and attach as `leadingComments` to the next import or pragma token. On emit, each token writes its leading comments (in original order) before the import line itself. Sorting operates only on `token.text`, so comments travel with their import to its final sorted position. +Blank lines inside the block are preserved in spirit, not position. While scanning, a blank line sets `leadingBlank = true` on the next non-blank token (multiple consecutive blanks collapse to a single `true`). On emit, blank lines within a category follow an **all-or-nothing** rule: if *any* token in the category had `leadingBlank = true` in the input, the output inserts a blank line between every adjacent pair of tokens in that category. Otherwise, the category is emitted tightly packed. This captures the user's intent ("I want breathing room in this group") without trying to preserve exact pre-sort positions, which become ambiguous after reordering. + +Structural separators around and between categories are independent of `leadingBlank` and always normalized: + +- Exactly one blank line between the preamble and the block (if the preamble is non-empty). +- Exactly one blank line between adjacent category groups. +- Exactly one blank line between the block and the body (if the body is non-empty). + +Multiple blank lines in any of these separator positions in the input collapse to one. Blank lines *inside* the preamble (between comment lines, before trailing blanks) are preserved byte-for-byte — the preamble is passthrough except for its trailing blank lines, which are part of the preamble-to-block separator and get normalized. + +**Leading blank lines at the top of the file** are stripped entirely. The preamble effectively starts at the first non-blank line; nothing meaningful lives above it. + +**Block boundaries**: the pragma/import block starts at the first pragma or import line and ends at the **last** pragma or import line (inclusive). Comments that appear before the first pragma/import are preamble (this is what keeps license headers put regardless of whether the user included a blank line between them and the imports). Comments between pragma/import lines attach as leading comments to the following import or pragma. Comments after the last pragma/import are body. Under this rule, an "orphan" comment — one with no following import — never exists inside the block; it is always either preamble or body. + +**Duplicates are removed.** Two tokens are considered duplicates when their normalized `text` is byte-identical (same kind, same whitespace-normalized import/pragma line, same alias if any). The first occurrence wins and keeps its `leadingComments` and `leadingBlank`; subsequent occurrences are dropped entirely, including any comments attached to them. Applies to both pragmas and imports. + +**Whitespace inside lines** is handled per line-kind: + +- **Pragma and import lines**: classified after trimming leading whitespace, so indented imports are recognized as imports. On emit they are rendered canonical — no leading whitespace, no trailing whitespace, and runs of internal whitespace (spaces or tabs between tokens) collapsed to single spaces. `import QtQuick 2.15` and `import\tQtQuick\t2.15` both become `import QtQuick 2.15`. Whitespace inside quoted strings is preserved — quoted paths are single tokens, so `import "./my file.qml"` becomes `import "./my file.qml"` with the internal double-space intact. A trailing comment on the same line as an import or pragma is kept as part of that line's text; it participates in sorting and in whitespace collapse. Users who want a comment to travel independently should put it on its own line. +- **Line comments (`//`)**: trimmed and emitted canonical. Leading whitespace is stripped; an indented `// note` becomes just `// note`. +- **Block-comment lines** — those whose trimmed content starts with `/*`, `*`, or `*/`: preserved byte-for-byte, including leading whitespace. This is what keeps multi-line block comment `*` alignment intact. +- **Whitespace-only lines** (any mix of spaces and tabs, nothing else) count as blank lines and feed into the blank-line rules above. + **Why this shape** - Bytes-in/bytes-out is the simplest possible contract. Any caller that can produce bytes (file, stdin, in-memory buffer, test fixture) can use it. @@ -67,33 +100,33 @@ The I/O shell. Wraps `qml.Format` with the file operations the CLI needs. ```go // FormatStream reads QML content from src, formats it via qml.Format, -// and writes the result to dst. +// and writes the result to dst. opts is forwarded to qml.Format. // Returns (changed, err) where changed reports whether the formatted // output differs byte-for-byte from the input. // // Used by: --stdin (dst = os.Stdout), --stdin --check (dst = io.Discard). -func FormatStream(src io.Reader, dst io.Writer) (changed bool, err error) +func FormatStream(src io.Reader, dst io.Writer, opts qml.Options) (changed bool, err error) // FormatFile formats path in place using an atomic write (temp file -// in the same directory + rename). +// in the same directory + rename). opts is forwarded to qml.Format. // Returns (changed, err) where changed reports whether the file's // content on disk differs after formatting. // File mode is preserved across the rename. // // Used by: default write mode. -func FormatFile(path string) (changed bool, err error) +func FormatFile(path string, opts qml.Options) (changed bool, err error) // CheckFile reports whether formatting path would change its content. -// Does not write. +// Does not write. opts is forwarded to qml.Format. // // Used by: --check. -func CheckFile(path string) (wouldChange bool, err error) +func CheckFile(path string, opts qml.Options) (wouldChange bool, err error) // FormatFileTo reads path, formats it, writes to dst. -// Does not modify the file on disk. +// Does not modify the file on disk. opts is forwarded to qml.Format. // // Used by: --stdout. -func FormatFileTo(path string, dst io.Writer) error +func FormatFileTo(path string, dst io.Writer, opts qml.Options) error // WalkQMLFiles walks root recursively, calling fn(path) for each regular // file whose name ends in ".qml". Entries whose name begins with "." @@ -122,16 +155,16 @@ ______________________________________________________________________ ### How the CLI modes compose -Each flag mode in [CLI.md](CLI.md) maps to a small combination of the primitives above. `main` contains no formatting logic — only dispatch. +Each flag mode in [CLI.md](CLI.md) maps to a small combination of the primitives above. `main` contains no formatting logic — only dispatch. `main` builds a `qml.Options` from `--library-prefix` / `--module-prefix` flags and threads the same `opts` value through every fs call in one invocation. -| Mode | Composition | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `qmlimportsort a.qml` | `fs.FormatFile("a.qml")` | -| `qmlimportsort src/` | `fs.WalkQMLFiles("src/", fs.FormatFile)` — except `fn` needs to match `func(string) error`, so wrap: `fn := func(p string) error { _, err := fs.FormatFile(p); return err }` | -| `qmlimportsort --check src/` | `fs.WalkQMLFiles("src/", func(p) { if change, _ := fs.CheckFile(p); change { print(p); anyChanged = true } })` | -| `qmlimportsort --stdout a.qml` | `fs.FormatFileTo("a.qml", os.Stdout)` | -| `qmlimportsort --stdin` | `fs.FormatStream(os.Stdin, os.Stdout)` | -| `qmlimportsort --stdin --check` | `changed, _ := fs.FormatStream(os.Stdin, io.Discard)` — exit 1 if `changed` | +| Mode | Composition | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `qmlimportsort a.qml` | `fs.FormatFile("a.qml", opts)` | +| `qmlimportsort src/` | `fs.WalkQMLFiles("src/", func(p) { _, err := fs.FormatFile(p, opts); return err })` | +| `qmlimportsort --check src/` | `fs.WalkQMLFiles("src/", func(p) { if c, _ := fs.CheckFile(p, opts); c { print(p); anyChanged = true } })` | +| `qmlimportsort --stdout a.qml` | `fs.FormatFileTo("a.qml", os.Stdout, opts)` | +| `qmlimportsort --stdin` | `fs.FormatStream(os.Stdin, os.Stdout, opts)` | +| `qmlimportsort --stdin --check` | `changed, _ := fs.FormatStream(os.Stdin, io.Discard, opts)` — exit 1 if `changed` | ______________________________________________________________________ From ccd25c69670d98fc7e86b79f832f87086311feed Mon Sep 17 00:00:00 2001 From: Elias Mueller Date: Fri, 24 Apr 2026 19:03:52 +0200 Subject: [PATCH 8/8] chore: add CLAUDE.md --- CLAUDE.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3204dc4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ + + +# Author + +- The author has experience in Java, Kotlin, Python and TypeScript and wants to pick up Go next. + +# Documentation + +- Update design decisions in the docs/devel folder. Keep it short, reference doc comments if available and applicable. + +# Dev Environment + +- Use the Justfile to check what tasks are available +- Add common reoccurring tasks to the Justfile + +# Coding + +- Only document public functions, otherwise: keep comments to a minimum +- Write clean code and use good names +- Adhere to best practices +- Prefer modern idioms and new language features if they improve clarity + +# Commiting + +- Use conventional commits +- Follow the 50/72 rule +- Describe why it has changed +- Do not list individual files +- Add yourself as co-author