diff --git a/.drun/spec.drun b/.drun/spec.drun index 2067b83..fa40708 100644 --- a/.drun/spec.drun +++ b/.drun/spec.drun @@ -3,7 +3,7 @@ version: 2.0 -project "Drun" version "2.0": +project "Drun" version "2.27.0": requires tools: go >= "1.26.5" golangci-lint provision @@ -98,14 +98,17 @@ task "ci" mode "ci" means "Runs the whole CI pipeline": success "CI executed successfully end-to-end" task "prepare-release" means "Runs checks related to going to a next release": - requires $release_version as string matching semver_optional_v - step "Preflight. Proposed version: v{$release_version}" + requires $version as string matching semver_optional_v + step "Preflight. Proposed version: v{$version}" # Byte for byte comparison if file "./drun.tmLanguage.json" not matches file "./docs/docs/assets/languages/drun.tmLanguage.json": fail "There are different vendored versions of the TextMate grammar in the repo.\nPlease make sure they match each other before merging." # Magic method for comparison (you can do this manually if you want to) - git ensure $release_version is newer than latest version from drun-main + git ensure $version is newer than latest version from drun-main step "Preflight checks have passed. Will now run the CI." call task ci + step "Promoting unreleased changelog entries to {$version}" + promote changelog "CHANGELOG.md" to version "{$version}" + update project version to "{$version}" success "According to the current spec, you should be good to go." warning "But be mindful, with great power, comes great responsibility." diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..71160cb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + +## [2.27.0] - 2026-08-10 + +### Added + +- Added the `promote changelog` statement for [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) release management: `promote changelog "CHANGELOG.md" to version "X.Y.Z" [on "YYYY-MM-DD"]` moves the `## [Unreleased]` entries into a dated release section, leaves an emptied `Unreleased` skeleton behind, and rewrites `[Unreleased]: .../compare/...HEAD` comparison links when present. Re-running it for a version whose release section already exists merges new `Unreleased` entries into that section (a no-op when there is nothing new), so release preparation tasks stay idempotent. Honors dry runs and writes atomically with preserved permissions. +- Added LSP support for `promote changelog`: keyword completion and hover documentation with examples. +- Added TextMate grammar coverage for `promote changelog` in the vendored language artifacts. + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security diff --git a/docs/docs/assets/languages/drun.tmLanguage.json b/docs/docs/assets/languages/drun.tmLanguage.json index 5021b88..20c7d7d 100644 --- a/docs/docs/assets/languages/drun.tmLanguage.json +++ b/docs/docs/assets/languages/drun.tmLanguage.json @@ -103,6 +103,9 @@ { "include": "#file-value-actions" }, + { + "include": "#changelog-actions" + }, { "include": "#http-actions" }, @@ -1027,6 +1030,50 @@ } ] }, + "changelog-actions": { + "patterns": [ + { + "name": "meta.changelog.promote.drun", + "match": "^(\\s*)(promote)(\\s+)(changelog)(\\s+)(\"(?:[^\"\\\\]|\\\\.)*\")(\\s+)(to)(\\s+)(version)(\\s+)(\"(?:[^\"\\\\]|\\\\.)*\")(?:(\\s+)(on)(\\s+)(\"(?:[^\"\\\\]|\\\\.)*\"))?", + "captures": { + "2": { + "name": "support.type.action.drun" + }, + "4": { + "name": "support.constant.domain.drun" + }, + "6": { + "name": "string.quoted.double.path.drun", + "patterns": [ + { + "include": "#interpolation" + } + ] + }, + "8": { + "name": "keyword.operator.word.drun" + }, + "10": { + "name": "keyword.operator.word.drun" + }, + "12": { + "name": "string.quoted.double.drun", + "patterns": [ + { + "include": "#interpolation" + } + ] + }, + "14": { + "name": "keyword.operator.word.drun" + }, + "16": { + "name": "string.quoted.double.date.drun" + } + } + } + ] + }, "download-actions": { "patterns": [ { @@ -1055,6 +1102,42 @@ }, "network-actions": { "patterns": [ + { + "name": "meta.wait.duration.drun", + "match": "^(\\s*)(wait)(\\s+)(\\d+(?:\\.\\d+)?)(\\s+)(seconds?|minutes?|hours?)\\b", + "captures": { + "2": { + "name": "support.type.action.drun" + }, + "4": { + "name": "constant.numeric.drun" + }, + "6": { + "name": "storage.type.time-unit.drun" + } + } + }, + { + "name": "meta.wait.duration.variable.drun", + "match": "^(\\s*)(wait)(\\s+)(\\{)(\\$?[A-Za-z_][A-Za-z0-9_.-]*)(\\})(\\s+)(seconds?|minutes?|hours?)\\b", + "captures": { + "2": { + "name": "support.type.action.drun" + }, + "4": { + "name": "punctuation.section.interpolation.begin.drun" + }, + "5": { + "name": "variable.other.drun" + }, + "6": { + "name": "punctuation.section.interpolation.end.drun" + }, + "8": { + "name": "storage.type.time-unit.drun" + } + } + }, { "name": "meta.network.wait.drun", "match": "^(\\s*)(wait)(\\s+)(for)(\\s+)(service)(\\s+)(at)(\\s+)(\"(?:[^\"\\\\]|\\\\.)*\")(\\s+)(to)(\\s+)(be)(\\s+)(ready)", @@ -1614,11 +1697,11 @@ }, { "name": "support.type.action.drun", - "match": "\\b(?:info|step|warn|warning|error|success|fail|echo|run|exec|shell|output|config|create|copy|move|delete|read|write|append|backup|check|ensure|extract|archive|build|push|pull|tag|remove|start|starting|stop|scale|deploy|rollback|wait|open|ping|test|expect|download|upload|send|receive|fetch|clone|init|switch|merge|add|commit|status|log|show|detect|search|update|restart|orchestrate|execute|apply|describe|expose)\\b" + "match": "\\b(?:info|step|warn|warning|error|success|fail|echo|run|exec|shell|output|config|create|copy|move|delete|read|write|append|backup|check|ensure|extract|archive|build|push|pull|tag|remove|start|starting|stop|scale|deploy|rollback|wait|open|ping|test|expect|download|upload|send|receive|fetch|clone|init|switch|merge|add|commit|status|log|show|detect|search|update|promote|restart|orchestrate|execute|apply|describe|expose)\\b" }, { "name": "support.constant.domain.drun", - "match": "\\b(?:drun|drunhub|setup|teardown|docker|image|container|compose|replicas|rollout|pods|pod|ingress|manifest|manifests|namespace|port|registry|git|github|gitlab|generic|cli|filesystem|latest|tags|series|formats|metadata|authentication|ambient|refs|date|branch|checkout|repository|remote|changes|message|files|get|post|put|delete|patch|head|options|request|response|body|headers|header|endpoint|api|data|timeout|retry|follow|redirects|verify|ssl|auth|bearer|basic|token|user|password|content|type|accept|health|healthy|service|services|provision|ready|host|connection|strategy|sequential|parallel|dependency-based|circuit|breaker|failure|threshold|recovery|interval|retries|networks|external|required|autoprovision|driver|condition|dns|tcp|domain|record|expected|ip|ips|command|working|workdir|missing|force|recreate|deps|never|always|makefile|target|args|pre|post|jobs|verbose|allocate_tty|ssh|key|fallback|delay|path|startup|shutdown|discovery|metrics|enabled|labels|unavailable|max|min|consul|etcd|server|domains|ttl|cache|memory|cpu|limit|policy|orphans|period|env_file|available|installed|tool|tools|framework|environment|node|npm|yarn|pnpm|bun|python|pip|go|golang|cargo|java|maven|gradle|ruby|gem|php|composer|rust|make|kubectl|helm|terraform|aws|gcp|azure|ci|local|production|staging|development|react|vue|angular|django|rails|express|spring|laravel|line|match|pattern|email|format|concat|split|replace|secret|trim|uppercase|lowercase|prepend|join|slice|length|keys|values|transform|subtract|multiply|divide|modulo|property|filtered|sorted|reversed|unique|first|last|basename|dirname|extension|prefix|suffix|allow|permissions|dir|file|folder|any|running|current|all|exists|locally|attached)\\b" + "match": "\\b(?:drun|drunhub|changelog|setup|teardown|docker|image|container|compose|replicas|rollout|pods|pod|ingress|manifest|manifests|namespace|port|registry|git|github|gitlab|generic|cli|filesystem|latest|tags|series|formats|metadata|authentication|ambient|refs|date|branch|checkout|repository|remote|changes|message|files|get|post|put|delete|patch|head|options|request|response|body|headers|header|endpoint|api|data|timeout|retry|follow|redirects|verify|ssl|auth|bearer|basic|token|user|password|content|type|accept|health|healthy|service|services|provision|ready|host|connection|strategy|sequential|parallel|dependency-based|circuit|breaker|failure|threshold|recovery|interval|retries|networks|external|required|autoprovision|driver|condition|dns|tcp|domain|record|expected|ip|ips|command|working|workdir|missing|force|recreate|deps|never|always|makefile|target|args|pre|post|jobs|verbose|allocate_tty|ssh|key|fallback|delay|path|startup|shutdown|discovery|metrics|enabled|labels|unavailable|max|min|consul|etcd|server|domains|ttl|cache|memory|cpu|limit|policy|orphans|period|env_file|available|installed|tool|tools|framework|environment|node|npm|yarn|pnpm|bun|python|pip|go|golang|cargo|java|maven|gradle|ruby|gem|php|composer|rust|make|kubectl|helm|terraform|aws|gcp|azure|ci|local|production|staging|development|react|vue|angular|django|rails|express|spring|laravel|line|match|pattern|email|format|concat|split|replace|secret|trim|uppercase|lowercase|prepend|join|slice|length|keys|values|transform|subtract|multiply|divide|modulo|property|filtered|sorted|reversed|unique|first|last|basename|dirname|extension|prefix|suffix|allow|permissions|dir|file|folder|any|running|current|all|exists|locally|attached)\\b" } ] }, diff --git a/docs/docs/reference/language/built-in-actions.md b/docs/docs/reference/language/built-in-actions.md index 7f5a31c..f150a84 100644 --- a/docs/docs/reference/language/built-in-actions.md +++ b/docs/docs/reference/language/built-in-actions.md @@ -537,6 +537,54 @@ command when a file falls outside these rules. The existing literal `replace in` action remains unchanged and independent of structured file-value operations. +#### Changelog promotion + +Release tasks commonly move the `Unreleased` entries of a [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +file into a dated release section. Drun supports this directly: + +```drun +promote changelog "CHANGELOG.md" to version "{$release_version}" +promote changelog "CHANGELOG.md" to version "1.5.0" on "2026-09-01" +``` + +The grammar is: + +```text +promote changelog to version [on ] +``` + +The statement rewrites the file so the previous `## [Unreleased]` body becomes a new `## [] - ` +section, inserted directly below a freshly emptied `## [Unreleased]` section that keeps the subsection skeleton it had +(`### Added`, `### Changed`, and so on). When the file carries an +`[Unreleased]: /compare/...HEAD` link definition, it is rewritten to compare from the new release and +a matching `[]: /compare/...` link is inserted below it. Files without comparison +links are promoted without link handling. + +`` is interpolated, tolerates a leading `v`, and must otherwise be a plain semantic version (`X.Y.Z`). +`` must be a valid calendar date in `YYYY-MM-DD` form and defaults to the current local date. Promotion fails +when the file has no `## [Unreleased]` heading. + +Promotion is idempotent so a release preparation can be re-run before the release is actually published: when the +`## []` section already exists, new `Unreleased` entries are merged into it (matching subsections are +appended to, new subsections are added, and the section's date and comparison links stay untouched), while an emptied +`Unreleased` section is a no-op. The statement participates in dry runs and writes with the same +permission-preserving atomic replacement as structured file-value updates. + +A complete release task typically combines it with structured updates: + +```drun +task "prepare-release" means "Prepare and verify a developer-selected release version": + requires $version as string matching semver_optional_v + + set $release_version to "{$version without prefix 'v'}" + + update json "/version" in "package.json" to "{$release_version}" or fail + promote changelog "CHANGELOG.md" to version "{$release_version}" +``` + +The complete executable example is +[`examples/75-changelog-promotion.drun`](https://github.com/phillarmonic/drun/blob/master/examples/75-changelog-promotion.drun). + #### File Inspection ```drun @@ -858,6 +906,38 @@ task "download_config": allow permissions ["read","write"] to ["user","group","others"] ``` +#### Pausing Execution (Fixed Waits) + +Pause task execution for a fixed amount of time before continuing with the +next statement. Durations can be given in seconds, minutes, or hours +(singular and plural forms are interchangeable), and the amount can be a +number literal or an interpolated variable. + +```drun +wait 5 seconds +wait 1 minute +wait 2 hours + +# Variable-driven durations, e.g. exponential-style backoff +let $backoff = "30" +wait {$backoff} seconds + +# Retry loop with a growing pause between attempts +for each $attempt in ["1", "2", "3"]: + try: + run "./flaky-deploy.sh" + break + catch: + warn "Attempt {$attempt} failed, backing off" + wait {$attempt} minutes +``` + +Fractional values are supported (e.g. `wait 0.5 seconds`, `wait 1.5 minutes`). +In dry-run mode the wait is reported but not performed. + +> **Note:** To wait until a service responds rather than for a fixed amount of +> time, use `wait for service at ... to be ready` (see below). + #### Network Health Checks and Service Waiting ```drun diff --git a/docs/docs/reference/language/grammar-and-lexical-structure.md b/docs/docs/reference/language/grammar-and-lexical-structure.md index d2ef8f9..1d73a2d 100644 --- a/docs/docs/reference/language/grammar-and-lexical-structure.md +++ b/docs/docs/reference/language/grammar-and-lexical-structure.md @@ -28,6 +28,18 @@ that operate on the project declaration in the currently executing Drun file. They honor custom `--file` paths and use the same dry-run and atomic-write guarantees as other structured file-value statements. +### Changelog statements + +```ebnf +changelog-promote = "promote", "changelog", string, "to", "version", string, + ["on", string] ; +``` + +The first string is the changelog file path, the second is the release +version, and the optional `on` string is a `YYYY-MM-DD` release date. See +[Changelog promotion](built-in-actions.md#changelog-promotion) for the exact +transformation and failure modes. + ### Composite Git version guard ```ebnf diff --git a/docs/docs/reference/language/syntax.md b/docs/docs/reference/language/syntax.md index 3625692..61913f1 100644 --- a/docs/docs/reference/language/syntax.md +++ b/docs/docs/reference/language/syntax.md @@ -768,4 +768,25 @@ finally: cleanup temporary resources ``` +### Wait Statements + +Pause execution for a fixed duration before continuing: + +```drun +wait +wait {} +``` + +Where `` is `second`/`seconds`, `minute`/`minutes`, or `hour`/`hours`. + +```drun +wait 5 seconds +wait 1 minute +wait {$backoff} seconds +``` + +To wait until a service responds instead of for a fixed duration, see +`wait for service at "url" to be ready` in +[Built-in Actions](./built-in-actions.md#network-health-checks-and-service-waiting). + --- diff --git a/docs/docs/reference/language/types-and-control-flow.md b/docs/docs/reference/language/types-and-control-flow.md index 4d031f0..fb30b36 100644 --- a/docs/docs/reference/language/types-and-control-flow.md +++ b/docs/docs/reference/language/types-and-control-flow.md @@ -301,20 +301,18 @@ run in parallel: wait for all to complete ``` -#### Range Iteration +#### Retry with Backoff -```drun -for port from 3000 to 3005: - check if port {port} is available +Pause between attempts with a fixed `wait` (seconds, minutes, or hours): -for i from 1 to retry_count: +```drun +for each $attempt in ["1", "2", "3"]: try: - perform operation + run "./flaky-deploy.sh" break catch: - if i == retry_count: - fail "Max retries exceeded" - wait {i} seconds + warn "Attempt {$attempt} failed, backing off" + wait {$attempt} seconds ``` #### Filtered Iteration diff --git a/drun.tmLanguage.json b/drun.tmLanguage.json index 5021b88..20c7d7d 100644 --- a/drun.tmLanguage.json +++ b/drun.tmLanguage.json @@ -103,6 +103,9 @@ { "include": "#file-value-actions" }, + { + "include": "#changelog-actions" + }, { "include": "#http-actions" }, @@ -1027,6 +1030,50 @@ } ] }, + "changelog-actions": { + "patterns": [ + { + "name": "meta.changelog.promote.drun", + "match": "^(\\s*)(promote)(\\s+)(changelog)(\\s+)(\"(?:[^\"\\\\]|\\\\.)*\")(\\s+)(to)(\\s+)(version)(\\s+)(\"(?:[^\"\\\\]|\\\\.)*\")(?:(\\s+)(on)(\\s+)(\"(?:[^\"\\\\]|\\\\.)*\"))?", + "captures": { + "2": { + "name": "support.type.action.drun" + }, + "4": { + "name": "support.constant.domain.drun" + }, + "6": { + "name": "string.quoted.double.path.drun", + "patterns": [ + { + "include": "#interpolation" + } + ] + }, + "8": { + "name": "keyword.operator.word.drun" + }, + "10": { + "name": "keyword.operator.word.drun" + }, + "12": { + "name": "string.quoted.double.drun", + "patterns": [ + { + "include": "#interpolation" + } + ] + }, + "14": { + "name": "keyword.operator.word.drun" + }, + "16": { + "name": "string.quoted.double.date.drun" + } + } + } + ] + }, "download-actions": { "patterns": [ { @@ -1055,6 +1102,42 @@ }, "network-actions": { "patterns": [ + { + "name": "meta.wait.duration.drun", + "match": "^(\\s*)(wait)(\\s+)(\\d+(?:\\.\\d+)?)(\\s+)(seconds?|minutes?|hours?)\\b", + "captures": { + "2": { + "name": "support.type.action.drun" + }, + "4": { + "name": "constant.numeric.drun" + }, + "6": { + "name": "storage.type.time-unit.drun" + } + } + }, + { + "name": "meta.wait.duration.variable.drun", + "match": "^(\\s*)(wait)(\\s+)(\\{)(\\$?[A-Za-z_][A-Za-z0-9_.-]*)(\\})(\\s+)(seconds?|minutes?|hours?)\\b", + "captures": { + "2": { + "name": "support.type.action.drun" + }, + "4": { + "name": "punctuation.section.interpolation.begin.drun" + }, + "5": { + "name": "variable.other.drun" + }, + "6": { + "name": "punctuation.section.interpolation.end.drun" + }, + "8": { + "name": "storage.type.time-unit.drun" + } + } + }, { "name": "meta.network.wait.drun", "match": "^(\\s*)(wait)(\\s+)(for)(\\s+)(service)(\\s+)(at)(\\s+)(\"(?:[^\"\\\\]|\\\\.)*\")(\\s+)(to)(\\s+)(be)(\\s+)(ready)", @@ -1614,11 +1697,11 @@ }, { "name": "support.type.action.drun", - "match": "\\b(?:info|step|warn|warning|error|success|fail|echo|run|exec|shell|output|config|create|copy|move|delete|read|write|append|backup|check|ensure|extract|archive|build|push|pull|tag|remove|start|starting|stop|scale|deploy|rollback|wait|open|ping|test|expect|download|upload|send|receive|fetch|clone|init|switch|merge|add|commit|status|log|show|detect|search|update|restart|orchestrate|execute|apply|describe|expose)\\b" + "match": "\\b(?:info|step|warn|warning|error|success|fail|echo|run|exec|shell|output|config|create|copy|move|delete|read|write|append|backup|check|ensure|extract|archive|build|push|pull|tag|remove|start|starting|stop|scale|deploy|rollback|wait|open|ping|test|expect|download|upload|send|receive|fetch|clone|init|switch|merge|add|commit|status|log|show|detect|search|update|promote|restart|orchestrate|execute|apply|describe|expose)\\b" }, { "name": "support.constant.domain.drun", - "match": "\\b(?:drun|drunhub|setup|teardown|docker|image|container|compose|replicas|rollout|pods|pod|ingress|manifest|manifests|namespace|port|registry|git|github|gitlab|generic|cli|filesystem|latest|tags|series|formats|metadata|authentication|ambient|refs|date|branch|checkout|repository|remote|changes|message|files|get|post|put|delete|patch|head|options|request|response|body|headers|header|endpoint|api|data|timeout|retry|follow|redirects|verify|ssl|auth|bearer|basic|token|user|password|content|type|accept|health|healthy|service|services|provision|ready|host|connection|strategy|sequential|parallel|dependency-based|circuit|breaker|failure|threshold|recovery|interval|retries|networks|external|required|autoprovision|driver|condition|dns|tcp|domain|record|expected|ip|ips|command|working|workdir|missing|force|recreate|deps|never|always|makefile|target|args|pre|post|jobs|verbose|allocate_tty|ssh|key|fallback|delay|path|startup|shutdown|discovery|metrics|enabled|labels|unavailable|max|min|consul|etcd|server|domains|ttl|cache|memory|cpu|limit|policy|orphans|period|env_file|available|installed|tool|tools|framework|environment|node|npm|yarn|pnpm|bun|python|pip|go|golang|cargo|java|maven|gradle|ruby|gem|php|composer|rust|make|kubectl|helm|terraform|aws|gcp|azure|ci|local|production|staging|development|react|vue|angular|django|rails|express|spring|laravel|line|match|pattern|email|format|concat|split|replace|secret|trim|uppercase|lowercase|prepend|join|slice|length|keys|values|transform|subtract|multiply|divide|modulo|property|filtered|sorted|reversed|unique|first|last|basename|dirname|extension|prefix|suffix|allow|permissions|dir|file|folder|any|running|current|all|exists|locally|attached)\\b" + "match": "\\b(?:drun|drunhub|changelog|setup|teardown|docker|image|container|compose|replicas|rollout|pods|pod|ingress|manifest|manifests|namespace|port|registry|git|github|gitlab|generic|cli|filesystem|latest|tags|series|formats|metadata|authentication|ambient|refs|date|branch|checkout|repository|remote|changes|message|files|get|post|put|delete|patch|head|options|request|response|body|headers|header|endpoint|api|data|timeout|retry|follow|redirects|verify|ssl|auth|bearer|basic|token|user|password|content|type|accept|health|healthy|service|services|provision|ready|host|connection|strategy|sequential|parallel|dependency-based|circuit|breaker|failure|threshold|recovery|interval|retries|networks|external|required|autoprovision|driver|condition|dns|tcp|domain|record|expected|ip|ips|command|working|workdir|missing|force|recreate|deps|never|always|makefile|target|args|pre|post|jobs|verbose|allocate_tty|ssh|key|fallback|delay|path|startup|shutdown|discovery|metrics|enabled|labels|unavailable|max|min|consul|etcd|server|domains|ttl|cache|memory|cpu|limit|policy|orphans|period|env_file|available|installed|tool|tools|framework|environment|node|npm|yarn|pnpm|bun|python|pip|go|golang|cargo|java|maven|gradle|ruby|gem|php|composer|rust|make|kubectl|helm|terraform|aws|gcp|azure|ci|local|production|staging|development|react|vue|angular|django|rails|express|spring|laravel|line|match|pattern|email|format|concat|split|replace|secret|trim|uppercase|lowercase|prepend|join|slice|length|keys|values|transform|subtract|multiply|divide|modulo|property|filtered|sorted|reversed|unique|first|last|basename|dirname|extension|prefix|suffix|allow|permissions|dir|file|folder|any|running|current|all|exists|locally|attached)\\b" } ] }, diff --git a/examples/75-changelog-promotion.drun b/examples/75-changelog-promotion.drun new file mode 100644 index 0000000..d4c6f14 --- /dev/null +++ b/examples/75-changelog-promotion.drun @@ -0,0 +1,20 @@ +version: 2.0 + +project "changelog-promotion" version "1.4.0": + +task "default" means "Describe the changelog example without touching local files": + info "Run prepare release version=X.Y.Z against a project with a Keep a Changelog CHANGELOG.md" + +task "prepare release" means "Promote unreleased changelog entries to a dated release section": + requires $version as string matching semver_optional_v + + set $release_version to "{$version without prefix 'v'}" + + # Moves the "## [Unreleased]" entries of CHANGELOG.md into a dated + # "## [X.Y.Z] - " section and leaves an emptied Unreleased section + # behind. "[Unreleased]: .../compare/...HEAD" comparison links are + # updated as well when present. + promote changelog "CHANGELOG.md" to version "{$release_version}" + + # Pin the date instead of using today: + # promote changelog "CHANGELOG.md" to version "1.5.0" on "2026-09-01" diff --git a/internal/ast/ast_changelog.go b/internal/ast/ast_changelog.go new file mode 100644 index 0000000..fd77cfb --- /dev/null +++ b/internal/ast/ast_changelog.go @@ -0,0 +1,28 @@ +package ast + +import ( + "fmt" + + "github.com/phillarmonic/drun/v2/internal/lexer" +) + +// ChangelogStatement represents a Keep a Changelog promotion: +// +// promote changelog "CHANGELOG.md" to version "1.5.0" +// promote changelog "CHANGELOG.md" to version "1.5.0" on "2026-09-01" +type ChangelogStatement struct { + Token lexer.Token + Path string // Raw changelog file path (interpolated at execution) + Version string // Raw release version (interpolated at execution) + Date string // Optional release date override (YYYY-MM-DD), empty means today +} + +func (cs *ChangelogStatement) statementNode() {} + +func (cs *ChangelogStatement) String() string { + result := fmt.Sprintf("promote changelog %q to version %q", cs.Path, cs.Version) + if cs.Date != "" { + result += fmt.Sprintf(" on %q", cs.Date) + } + return result +} diff --git a/internal/ast/ast_wait.go b/internal/ast/ast_wait.go new file mode 100644 index 0000000..04392a3 --- /dev/null +++ b/internal/ast/ast_wait.go @@ -0,0 +1,23 @@ +package ast + +import ( + "fmt" + + "github.com/phillarmonic/drun/v2/internal/lexer" +) + +// WaitStatement represents a fixed-duration wait (wait 5 seconds, wait {retries} minutes) +type WaitStatement struct { + Token lexer.Token + Value string // Raw value: a number literal or a {variable} interpolation + Unit string // Normalized singular unit: "second", "minute", "hour" +} + +func (ws *WaitStatement) statementNode() {} +func (ws *WaitStatement) String() string { + unit := ws.Unit + if ws.Value != "1" { + unit += "s" + } + return fmt.Sprintf("wait %s %s", ws.Value, unit) +} diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 0000000..71f1254 --- /dev/null +++ b/internal/changelog/changelog.go @@ -0,0 +1,282 @@ +// Package changelog implements Keep a Changelog (https://keepachangelog.com) +// promotion: moving the Unreleased section into a dated release section. +package changelog + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +var ( + unreleasedHeadingRe = regexp.MustCompile(`^## \[Unreleased\]\s*$`) + releaseHeadingRe = regexp.MustCompile(`^## \[([^]]+)\]`) + subsectionHeadingRe = regexp.MustCompile(`^### \S`) + linkDefinitionRe = regexp.MustCompile(`^\[[^]]+\]:\s*\S+`) + unreleasedLinkRe = regexp.MustCompile(`^\[Unreleased\]:\s*(\S+)/compare/(v?)([^/.]+(?:\.[^/.]+)*)\.\.\.HEAD\s*$`) + versionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`) +) + +// NormalizeVersion strips an optional leading "v" and validates that the +// result is a plain semantic version (X.Y.Z). +func NormalizeVersion(raw string) (string, error) { + version := strings.TrimPrefix(strings.TrimSpace(raw), "v") + if !versionRe.MatchString(version) { + return "", fmt.Errorf("version %q is not a semantic version (expected X.Y.Z)", raw) + } + return version, nil +} + +// ParseDate validates a YYYY-MM-DD release date override. +func ParseDate(raw string) (time.Time, error) { + date, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.Local) + if err != nil { + return time.Time{}, fmt.Errorf("date %q is not a valid calendar date (expected YYYY-MM-DD)", raw) + } + return date, nil +} + +// Promote moves the Unreleased entries of a Keep a Changelog document into a +// dated release section for version, leaving an emptied Unreleased section +// behind. When the document carries an "[Unreleased]: .../compare/...HEAD" +// link definition, the comparison links are updated as well. +// +// Promotion is idempotent so release preparation can be re-run before the +// release is actually published: when the release section already exists, new +// Unreleased entries are merged into it (its date and comparison links stay +// untouched), and an empty Unreleased section is a no-op. +func Promote(content, rawVersion string, date time.Time) (string, error) { + version, err := NormalizeVersion(rawVersion) + if err != nil { + return "", err + } + + lines := strings.Split(content, "\n") + + unreleasedAt := -1 + for i, line := range lines { + if unreleasedHeadingRe.MatchString(line) { + unreleasedAt = i + break + } + } + if unreleasedAt == -1 { + return "", fmt.Errorf("no '## [Unreleased]' section found") + } + + // The Unreleased body runs until the next section heading or the link + // definition block, whichever comes first. + bodyEnd := len(lines) + for i := unreleasedAt + 1; i < len(lines); i++ { + if strings.HasPrefix(lines[i], "## ") || linkDefinitionRe.MatchString(lines[i]) { + bodyEnd = i + break + } + } + body := trimBlankLines(lines[unreleasedAt+1 : bodyEnd]) + rest := trimLeadingBlankLines(lines[bodyEnd:]) + + // The emptied Unreleased section keeps the subsection skeleton it had. + skeleton := []string{"## [Unreleased]", ""} + for _, line := range body { + if subsectionHeadingRe.MatchString(line) { + skeleton = append(skeleton, line, "") + } + } + + // A release section for this version may already exist from a previous + // prepare run; merge into it instead of failing. + releaseAt := findReleaseSection(rest, version) + if releaseAt != -1 { + entries := parseSectionBody(body) + if entries.isEmpty() { + return content, nil + } + rest = mergeReleaseSection(rest, releaseAt, entries) + out := make([]string, 0, len(lines)+len(skeleton)) + out = append(out, lines[:unreleasedAt]...) + out = append(out, skeleton...) + out = append(out, rest...) + return strings.Join(out, "\n"), nil + } + + release := []string{fmt.Sprintf("## [%s] - %s", version, date.Format("2006-01-02")), ""} + release = append(release, body...) + release = append(release, "") + + rest = updateCompareLinks(rest, version) + + out := make([]string, 0, len(lines)+len(skeleton)+len(release)) + out = append(out, lines[:unreleasedAt]...) + out = append(out, skeleton...) + out = append(out, release...) + out = append(out, rest...) + return strings.Join(out, "\n"), nil +} + +// findReleaseSection locates a "## [] ..." heading in lines, +// tolerating a leading "v" in the heading. Returns -1 when absent. +func findReleaseSection(lines []string, version string) int { + for i, line := range lines { + if match := releaseHeadingRe.FindStringSubmatch(line); match != nil { + if strings.TrimPrefix(match[1], "v") == version { + return i + } + } + } + return -1 +} + +// subsection is one "### ..." block of a release section. +type subsection struct { + heading string + lines []string +} + +// sectionBody is the parsed content of a release section: free-form preamble +// lines followed by subsections. +type sectionBody struct { + preamble []string + subs []subsection +} + +func (s sectionBody) isEmpty() bool { + if len(s.preamble) > 0 { + return false + } + for _, sub := range s.subs { + if len(sub.lines) > 0 { + return false + } + } + return true +} + +// parseSectionBody splits trimmed section body lines into preamble and +// subsections. Blank runs separate blocks; empty subsections are preserved so +// the skeleton shape survives a merge. +func parseSectionBody(body []string) sectionBody { + var result sectionBody + current := -1 + for _, line := range body { + if subsectionHeadingRe.MatchString(line) { + result.subs = append(result.subs, subsection{heading: line}) + current = len(result.subs) - 1 + continue + } + if current == -1 { + result.preamble = append(result.preamble, line) + } else { + result.subs[current].lines = append(result.subs[current].lines, line) + } + } + result.preamble = trimBlankLines(result.preamble) + for i := range result.subs { + result.subs[i].lines = trimBlankLines(result.subs[i].lines) + } + return result +} + +// renderSectionBody renders a merged section body in canonical form: preamble, +// then each non-empty subsection, blocks separated by single blank lines. +func renderSectionBody(body sectionBody) []string { + var out []string + if len(body.preamble) > 0 { + out = append(out, body.preamble...) + } + for _, sub := range body.subs { + if len(sub.lines) == 0 { + continue + } + if len(out) > 0 { + out = append(out, "") + } + out = append(out, sub.heading, "") + out = append(out, sub.lines...) + } + return out +} + +// mergeReleaseSection folds new Unreleased entries into the existing release +// section starting at releaseAt within lines. Matching subsections are +// appended to; new subsections and preamble lines are added in order. The +// section heading (including its date) is preserved verbatim. +func mergeReleaseSection(lines []string, releaseAt int, entries sectionBody) []string { + bodyEnd := len(lines) + for i := releaseAt + 1; i < len(lines); i++ { + if strings.HasPrefix(lines[i], "## ") || linkDefinitionRe.MatchString(lines[i]) { + bodyEnd = i + break + } + } + + target := parseSectionBody(trimBlankLines(lines[releaseAt+1 : bodyEnd])) + target.preamble = append(target.preamble, entries.preamble...) + for _, incoming := range entries.subs { + if len(incoming.lines) == 0 { + continue + } + merged := false + for i := range target.subs { + if target.subs[i].heading == incoming.heading { + target.subs[i].lines = append(target.subs[i].lines, incoming.lines...) + merged = true + break + } + } + if !merged { + target.subs = append(target.subs, incoming) + } + } + + merged := renderSectionBody(target) + + out := make([]string, 0, len(lines)+len(entries.preamble)+8) + out = append(out, lines[:releaseAt+1]...) + if len(merged) > 0 { + out = append(out, "") + out = append(out, merged...) + } + out = append(out, "") + out = append(out, lines[bodyEnd:]...) + return out +} + +// updateCompareLinks rewrites the Unreleased comparison link to start at the +// new release and inserts the new release's own comparison link after it. +// Documents without an Unreleased link are returned unchanged. +func updateCompareLinks(lines []string, version string) []string { + for i, line := range lines { + match := unreleasedLinkRe.FindStringSubmatch(line) + if match == nil { + continue + } + base, prefix, previous := match[1], match[2], match[3] + updated := make([]string, 0, len(lines)+1) + updated = append(updated, lines[:i]...) + updated = append(updated, fmt.Sprintf("[Unreleased]: %s/compare/%s%s...HEAD", base, prefix, version)) + updated = append(updated, fmt.Sprintf("[%s]: %s/compare/%s%s...%s%s", version, base, prefix, previous, prefix, version)) + updated = append(updated, lines[i+1:]...) + return updated + } + return lines +} + +func trimBlankLines(lines []string) []string { + return trimTrailingBlankLines(trimLeadingBlankLines(lines)) +} + +func trimLeadingBlankLines(lines []string) []string { + for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { + lines = lines[1:] + } + return lines +} + +func trimTrailingBlankLines(lines []string) []string { + for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + return lines +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go new file mode 100644 index 0000000..6f663a1 --- /dev/null +++ b/internal/changelog/changelog_test.go @@ -0,0 +1,305 @@ +package changelog + +import ( + "strings" + "testing" + "time" +) + +func mustDate(t *testing.T, raw string) time.Time { + t.Helper() + date, err := ParseDate(raw) + if err != nil { + t.Fatalf("ParseDate(%q): %v", raw, err) + } + return date +} + +func TestNormalizeVersion(t *testing.T) { + tests := []struct { + raw string + want string + wantErr bool + }{ + {"1.5.0", "1.5.0", false}, + {"v1.5.0", "1.5.0", false}, + {" 1.5.0 ", "1.5.0", false}, + {"1.5", "", true}, + {"1.5.0-beta", "", true}, + {"banana", "", true}, + {"", "", true}, + } + for _, tt := range tests { + got, err := NormalizeVersion(tt.raw) + if tt.wantErr { + if err == nil { + t.Errorf("NormalizeVersion(%q): expected error, got %q", tt.raw, got) + } + continue + } + if err != nil { + t.Errorf("NormalizeVersion(%q): %v", tt.raw, err) + continue + } + if got != tt.want { + t.Errorf("NormalizeVersion(%q) = %q, want %q", tt.raw, got, tt.want) + } + } +} + +func TestParseDate(t *testing.T) { + if _, err := ParseDate("2026-08-10"); err != nil { + t.Errorf("ParseDate valid date: %v", err) + } + for _, raw := range []string{"2026-02-30", "2026-13-01", "10-08-2026", "banana", ""} { + if _, err := ParseDate(raw); err == nil { + t.Errorf("ParseDate(%q): expected error, got none", raw) + } + } +} + +func TestPromoteWithSubsectionsAndCompareLinks(t *testing.T) { + content := `# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] +Upstream support for Drun v2.27 +### Added + +- Added syntax highlighting support. + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + +[Unreleased]: https://github.com/acme/widget/compare/v1.2.0...HEAD +` + + want := `# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + +## [1.5.0] - 2026-08-10 + +Upstream support for Drun v2.27 +### Added + +- Added syntax highlighting support. + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + +[Unreleased]: https://github.com/acme/widget/compare/v1.5.0...HEAD +[1.5.0]: https://github.com/acme/widget/compare/v1.2.0...v1.5.0 +` + + got, err := Promote(content, "v1.5.0", mustDate(t, "2026-08-10")) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if got != want { + t.Errorf("Promote mismatch\ngot:\n%s\nwant:\n%s", got, want) + } +} + +func TestPromoteBeforeExistingReleases(t *testing.T) { + content := `# Changelog + +## [Unreleased] + +### Added + +- New thing. + +## [1.2.0] - 2026-07-15 + +### Added + +- Old thing. + +[Unreleased]: https://github.com/acme/widget/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/acme/widget/releases/tag/v1.2.0 +` + + want := `# Changelog + +## [Unreleased] + +### Added + +## [1.3.0] - 2026-08-10 + +### Added + +- New thing. + +## [1.2.0] - 2026-07-15 + +### Added + +- Old thing. + +[Unreleased]: https://github.com/acme/widget/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/acme/widget/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/acme/widget/releases/tag/v1.2.0 +` + + got, err := Promote(content, "1.3.0", mustDate(t, "2026-08-10")) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if got != want { + t.Errorf("Promote mismatch\ngot:\n%s\nwant:\n%s", got, want) + } +} + +func TestPromoteWithoutSubsectionsOrLinks(t *testing.T) { + content := `# Changelog + +## [Unreleased] + +- Loose entry without subsections. +` + + want := `# Changelog + +## [Unreleased] + +## [0.2.0] - 2026-08-10 + +- Loose entry without subsections. +` + + got, err := Promote(content, "0.2.0", mustDate(t, "2026-08-10")) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if got != want { + t.Errorf("Promote mismatch\ngot:\n%s\nwant:\n%s", got, want) + } +} + +func TestPromoteMergesIntoExistingReleaseSection(t *testing.T) { + content := `# Changelog + +## [Unreleased] + +### Added + +- Another new thing. + +### Fixed + +- A fix. + +## [1.3.0] - 2026-08-10 + +### Added + +- New thing. + +[Unreleased]: https://github.com/acme/widget/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/acme/widget/compare/v1.2.0...v1.3.0 +` + + want := `# Changelog + +## [Unreleased] + +### Added + +### Fixed + +## [1.3.0] - 2026-08-10 + +### Added + +- New thing. +- Another new thing. + +### Fixed + +- A fix. + +[Unreleased]: https://github.com/acme/widget/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/acme/widget/compare/v1.2.0...v1.3.0 +` + + got, err := Promote(content, "1.3.0", mustDate(t, "2026-08-15")) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if got != want { + t.Errorf("Promote mismatch\ngot:\n%s\nwant:\n%s", got, want) + } +} + +func TestPromoteWithExistingSectionAndEmptyUnreleasedIsNoOp(t *testing.T) { + content := `# Changelog + +## [Unreleased] + +### Added + +### Changed + +## [1.3.0] - 2026-08-10 + +### Added + +- New thing. + +[Unreleased]: https://github.com/acme/widget/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/acme/widget/compare/v1.2.0...v1.3.0 +` + + got, err := Promote(content, "v1.3.0", mustDate(t, "2026-08-15")) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if got != content { + t.Errorf("expected a byte-identical no-op\ngot:\n%s\nwant:\n%s", got, content) + } +} + +func TestPromoteErrors(t *testing.T) { + date := mustDate(t, "2026-08-10") + + if _, err := Promote("# Changelog\n\n## [1.0.0] - 2026-01-01\n", "1.1.0", date); err == nil || + !strings.Contains(err.Error(), "Unreleased") { + t.Errorf("missing Unreleased section: %v", err) + } + + existing := "# Changelog\n\n## [Unreleased]\n\n- x\n\n## [1.0.0] - 2026-01-01\n\n- y\n" + if _, err := Promote(existing, "banana", date); err == nil { + t.Error("invalid version: expected error, got none") + } +} diff --git a/internal/domain/statement/converter.go b/internal/domain/statement/converter.go index 007eb86..ac77d95 100644 --- a/internal/domain/statement/converter.go +++ b/internal/domain/statement/converter.go @@ -224,6 +224,12 @@ func FromAST(astStmt ast.Statement) (Statement, error) { Condition: s.Condition, }, nil + case *ast.WaitStatement: + return &Wait{ + Value: s.Value, + Unit: s.Unit, + }, nil + case *ast.FileStatement: return &File{ Action: s.Action, @@ -243,6 +249,11 @@ func FromAST(astStmt ast.Statement) (Statement, error) { ValueType: s.ValueType, }, nil + case *ast.ChangelogStatement: + return &Changelog{ + Path: s.Path, Version: s.Version, Date: s.Date, + }, nil + case *ast.DetectionStatement: body, err := FromASTList(s.Body) if err != nil { diff --git a/internal/domain/statement/converter_test.go b/internal/domain/statement/converter_test.go index 5ac72b2..16e2c9e 100644 --- a/internal/domain/statement/converter_test.go +++ b/internal/domain/statement/converter_test.go @@ -172,3 +172,34 @@ func TestFromASTList_SkipsNil(t *testing.T) { t.Errorf("Result length = %v, want 2 (parameter should be skipped)", len(domainList)) } } + +func TestFromAST_Changelog(t *testing.T) { + astChangelog := &ast.ChangelogStatement{ + Path: "CHANGELOG.md", + Version: "{$release_version}", + Date: "2026-09-01", + } + + domainStmt, err := FromAST(astChangelog) + if err != nil { + t.Fatalf("FromAST() error = %v", err) + } + + changelogStmt, ok := domainStmt.(*Changelog) + if !ok { + t.Fatalf("Expected *Changelog, got %T", domainStmt) + } + + if changelogStmt.Type() != TypeChangelog { + t.Errorf("Type() = %v, want %v", changelogStmt.Type(), TypeChangelog) + } + if changelogStmt.Path != "CHANGELOG.md" { + t.Errorf("Path = %v, want CHANGELOG.md", changelogStmt.Path) + } + if changelogStmt.Version != "{$release_version}" { + t.Errorf("Version = %v, want {$release_version}", changelogStmt.Version) + } + if changelogStmt.Date != "2026-09-01" { + t.Errorf("Date = %v, want 2026-09-01", changelogStmt.Date) + } +} diff --git a/internal/domain/statement/statement.go b/internal/domain/statement/statement.go index 803bc4b..48d4147 100644 --- a/internal/domain/statement/statement.go +++ b/internal/domain/statement/statement.go @@ -29,8 +29,10 @@ const ( TypeHTTP StatementType = "http" TypeDownload StatementType = "download" TypeNetwork StatementType = "network" + TypeWait StatementType = "wait" TypeFile StatementType = "file" TypeFileValue StatementType = "file_value" + TypeChangelog StatementType = "changelog" TypeDetection StatementType = "detection" TypeUseSnippet StatementType = "use_snippet" TypeSecret StatementType = "secret" @@ -265,6 +267,14 @@ type Network struct { func (n *Network) Type() StatementType { return TypeNetwork } +// Wait represents a fixed-duration wait (wait 5 seconds) +type Wait struct { + Value string // Raw value: a number literal or a {variable} interpolation + Unit string // Normalized singular unit: "second", "minute", "hour" +} + +func (w *Wait) Type() StatementType { return TypeWait } + // File represents file operations type File struct { Action string @@ -294,6 +304,16 @@ type FileValue struct { func (f *FileValue) Type() StatementType { return TypeFileValue } +// Changelog represents a Keep a Changelog promotion of the Unreleased +// section into a dated release section. +type Changelog struct { + Path string + Version string + Date string // Optional release date override (YYYY-MM-DD), empty means today +} + +func (c *Changelog) Type() StatementType { return TypeChangelog } + // Detection represents tool detection operations type Detection struct { DetectionType string // "detect", "detect_available", "if_available", "when_environment", "if_version" diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 53beaa4..c3b503b 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -952,10 +952,14 @@ func (e *Engine) executeStatement(stmt statement.Statement, ctx *ExecutionContex return e.executeDownload(s, ctx) case *statement.Network: return e.executeNetwork(s, ctx) + case *statement.Wait: + return e.executeWait(s, ctx) case *statement.File: return e.executeFile(s, ctx) case *statement.FileValue: return e.executeFileValue(s, ctx) + case *statement.Changelog: + return e.executeChangelog(s, ctx) case *statement.Detection: return e.executeDetection(s, ctx) case *statement.TaskCall: diff --git a/internal/engine/executor_changelog.go b/internal/engine/executor_changelog.go new file mode 100644 index 0000000..60114d2 --- /dev/null +++ b/internal/engine/executor_changelog.go @@ -0,0 +1,88 @@ +package engine + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/phillarmonic/drun/v2/internal/changelog" + "github.com/phillarmonic/drun/v2/internal/domain/statement" +) + +func (e *Engine) executeChangelog(stmt *statement.Changelog, ctx *ExecutionContext) error { + path := e.interpolateVariables(stmt.Path, ctx) + version := e.interpolateVariables(stmt.Version, ctx) + + date := time.Now() + if stmt.Date != "" { + parsed, err := changelog.ParseDate(e.interpolateVariables(stmt.Date, ctx)) + if err != nil { + return fmt.Errorf("promote changelog %q: %w", path, err) + } + date = parsed + } + + // #nosec G304 -- the Drun program explicitly supplies the path. + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("promote changelog %q: %w", path, err) + } + + updated, err := changelog.Promote(string(data), version, date) + if err != nil { + return fmt.Errorf("promote changelog %q: %w", path, err) + } + normalized, _ := changelog.NormalizeVersion(version) + + if e.dryRun { + _, _ = fmt.Fprintf(e.output, "[DRY RUN] Would promote changelog %s unreleased entries to version %s (%s)\n", path, normalized, date.Format("2006-01-02")) + return nil + } + + if string(data) == updated { + if e.verbose { + _, _ = fmt.Fprintf(e.output, "✅ Changelog %s is already up to date\n", path) + } + return nil + } + + if err := writeFileAtomic(path, []byte(updated)); err != nil { + return fmt.Errorf("promote changelog %q: %w", path, err) + } + if e.verbose { + _, _ = fmt.Fprintf(e.output, "✅ Promoted changelog %s unreleased entries to version %s (%s)\n", path, normalized, date.Format("2006-01-02")) + } + return nil +} + +// writeFileAtomic replaces path with data using a same-directory temporary +// file, preserving the original file permissions. +func writeFileAtomic(path string, data []byte) error { + info, err := os.Stat(path) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".drun-changelog-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if err := tmp.Chmod(info.Mode().Perm()); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/internal/engine/executor_changelog_test.go b/internal/engine/executor_changelog_test.go new file mode 100644 index 0000000..c9a7fe6 --- /dev/null +++ b/internal/engine/executor_changelog_test.go @@ -0,0 +1,191 @@ +package engine + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/phillarmonic/drun/v2/internal/domain/statement" + "github.com/phillarmonic/drun/v2/internal/lexer" + "github.com/phillarmonic/drun/v2/internal/parser" +) + +const changelogFixture = `# Changelog + +## [Unreleased] + +### Added + +- New thing. + +[Unreleased]: https://github.com/acme/widget/compare/v1.4.0...HEAD +` + +func TestExecuteChangelogPromoteAndDryRun(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + if err := os.WriteFile(path, []byte(changelogFixture), 0o644); err != nil { + t.Fatal(err) + } + + ctx := &ExecutionContext{Variables: map[string]string{"release_version": "1.5.0"}} + out := &bytes.Buffer{} + e := NewEngine(out) + + e.SetDryRun(true) + stmt := &statement.Changelog{Path: path, Version: "{$release_version}", Date: "2026-08-10"} + if err := e.executeChangelog(stmt, ctx); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "[DRY RUN]") { + t.Fatalf("dry run output = %q", out.String()) + } + data, _ := os.ReadFile(path) + if string(data) != changelogFixture { + t.Fatal("dry run mutated file") + } + + e.SetDryRun(false) + if err := e.executeChangelog(stmt, ctx); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(path) + got := string(data) + for _, want := range []string{ + "## [Unreleased]\n\n### Added\n\n## [1.5.0] - 2026-08-10", + "[Unreleased]: https://github.com/acme/widget/compare/v1.5.0...HEAD", + "[1.5.0]: https://github.com/acme/widget/compare/v1.4.0...v1.5.0", + } { + if !strings.Contains(got, want) { + t.Errorf("promoted changelog missing %q\ngot:\n%s", want, got) + } + } +} + +func TestExecuteChangelogDefaultsToToday(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + if err := os.WriteFile(path, []byte(changelogFixture), 0o644); err != nil { + t.Fatal(err) + } + + e := NewEngine(&bytes.Buffer{}) + ctx := &ExecutionContext{Variables: map[string]string{}} + if err := e.executeChangelog(&statement.Changelog{Path: path, Version: "1.5.0"}, ctx); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + want := "## [1.5.0] - " + time.Now().Format("2006-01-02") + if !strings.Contains(string(data), want) { + t.Fatalf("promoted changelog missing %q\ngot:\n%s", want, data) + } +} + +func TestExecuteChangelogErrors(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + if err := os.WriteFile(path, []byte(changelogFixture), 0o644); err != nil { + t.Fatal(err) + } + + e := NewEngine(&bytes.Buffer{}) + ctx := &ExecutionContext{Variables: map[string]string{}} + + if err := e.executeChangelog(&statement.Changelog{Path: path, Version: "1.5.0", Date: "2026-02-30"}, ctx); err == nil || + !strings.Contains(err.Error(), "not a valid calendar date") { + t.Fatalf("invalid date error = %v", err) + } + if err := e.executeChangelog(&statement.Changelog{Path: path, Version: "banana"}, ctx); err == nil || + !strings.Contains(err.Error(), "not a semantic version") { + t.Fatalf("invalid version error = %v", err) + } + if err := e.executeChangelog(&statement.Changelog{Path: filepath.Join(dir, "MISSING.md"), Version: "1.5.0"}, ctx); err == nil { + t.Fatal("missing file: expected error, got none") + } +} + +func TestExecuteChangelogRerunIsIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + if err := os.WriteFile(path, []byte(changelogFixture), 0o644); err != nil { + t.Fatal(err) + } + + e := NewEngine(&bytes.Buffer{}) + ctx := &ExecutionContext{Variables: map[string]string{}} + stmt := &statement.Changelog{Path: path, Version: "1.5.0", Date: "2026-08-10"} + + if err := e.executeChangelog(stmt, ctx); err != nil { + t.Fatal(err) + } + first, _ := os.ReadFile(path) + + // Re-running the same release preparation with an emptied Unreleased + // section must not fail or change the file. + if err := e.executeChangelog(stmt, ctx); err != nil { + t.Fatalf("re-run with empty unreleased section: %v", err) + } + second, _ := os.ReadFile(path) + if string(first) != string(second) { + t.Fatal("re-run mutated the changelog") + } + + // Entries added after the first run merge into the existing section. + updated := strings.Replace(string(second), "## [Unreleased]\n\n### Added", + "## [Unreleased]\n\n### Added\n\n- Late addition.", 1) + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + t.Fatal(err) + } + if err := e.executeChangelog(stmt, ctx); err != nil { + t.Fatalf("re-run with new entries: %v", err) + } + merged, _ := os.ReadFile(path) + text := string(merged) + if !strings.Contains(text, "- New thing.\n- Late addition.") { + t.Fatalf("late entry was not merged into the existing section\ngot:\n%s", text) + } + if strings.Count(text, "## [1.5.0]") != 1 { + t.Fatalf("expected exactly one release section\ngot:\n%s", text) + } + if strings.Contains(text, "## [1.5.0] - 2026-08-10") == false { + t.Fatalf("existing release date must be preserved\ngot:\n%s", text) + } +} + +func TestChangelogPromotionEndToEnd(t *testing.T) { + dir := t.TempDir() + changelogPath := filepath.Join(dir, "CHANGELOG.md") + if err := os.WriteFile(changelogPath, []byte(changelogFixture), 0o644); err != nil { + t.Fatal(err) + } + + source := `version: 2.0 + +task "release": + requires $next + promote changelog "` + changelogPath + `" to version "{$next}" on "2026-08-10" +` + specPath := filepath.Join(dir, "release.drun") + if err := os.WriteFile(specPath, []byte(source), 0o644); err != nil { + t.Fatal(err) + } + + p := parser.NewParser(lexer.NewLexer(source)) + program := p.ParseProgram() + if len(p.Errors()) > 0 { + t.Fatalf("parser errors: %v", p.Errors()) + } + + engine := NewEngine(&bytes.Buffer{}) + if err := engine.ExecuteWithParamsAndFile(program, "release", map[string]string{"next": "1.5.0"}, specPath); err != nil { + t.Fatalf("execute: %v", err) + } + + data, _ := os.ReadFile(changelogPath) + if !strings.Contains(string(data), "## [1.5.0] - 2026-08-10") { + t.Fatalf("end-to-end promotion failed\ngot:\n%s", data) + } +} diff --git a/internal/engine/executor_wait.go b/internal/engine/executor_wait.go new file mode 100644 index 0000000..44ead30 --- /dev/null +++ b/internal/engine/executor_wait.go @@ -0,0 +1,58 @@ +package engine + +import ( + "fmt" + "strconv" + "time" + + "github.com/phillarmonic/drun/v2/internal/domain/statement" +) + +// Domain: Wait Execution +// This file contains the executor for fixed-duration waits (wait 5 seconds) + +// executeWait executes a fixed-duration wait (wait 5 seconds, wait {retries} minutes) +func (e *Engine) executeWait(waitStmt *statement.Wait, ctx *ExecutionContext) error { + // Interpolate variables in the duration value (e.g. wait {retries} seconds) + value, err := e.interpolateVariablesWithError(waitStmt.Value, ctx) + if err != nil { + return err + } + + amount, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid wait duration %q: expected a number", value) + } + if amount < 0 { + return fmt.Errorf("invalid wait duration %q: must not be negative", value) + } + + var unit time.Duration + switch waitStmt.Unit { + case "second": + unit = time.Second + case "minute": + unit = time.Minute + case "hour": + unit = time.Hour + default: + return fmt.Errorf("invalid wait unit %q: expected second(s), minute(s), or hour(s)", waitStmt.Unit) + } + + duration := time.Duration(amount * float64(unit)) + + // formatDuration floors to whole seconds; show milliseconds for sub-second waits + display := formatDuration(duration) + if duration < time.Second { + display = fmt.Sprintf("%dms", duration.Milliseconds()) + } + + if e.dryRun { + _, _ = fmt.Fprintf(e.output, "[DRY RUN] Would wait %s\n", display) + return nil + } + + _, _ = fmt.Fprintf(e.output, "⏳ Waiting %s...\n", display) + time.Sleep(duration) + return nil +} diff --git a/internal/engine/executor_wait_test.go b/internal/engine/executor_wait_test.go new file mode 100644 index 0000000..29120b1 --- /dev/null +++ b/internal/engine/executor_wait_test.go @@ -0,0 +1,84 @@ +package engine + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestEngine_WaitSleepsForDuration(t *testing.T) { + program, err := ParseString(`version: 2.0 + +task "demo": + wait 0.1 seconds + success "done" +`) + if err != nil { + t.Fatalf("ParseString() error = %v", err) + } + + var out bytes.Buffer + engine := NewEngine(&out) + + start := time.Now() + if err := engine.Execute(program, "demo"); err != nil { + t.Fatalf("Execute() error = %v", err) + } + elapsed := time.Since(start) + + if elapsed < 100*time.Millisecond { + t.Errorf("expected execution to wait at least 100ms, took %v", elapsed) + } + + if !strings.Contains(out.String(), "Waiting 100ms") { + t.Errorf("expected wait output, got: %q", out.String()) + } +} + +func TestEngine_WaitInterpolatesVariable(t *testing.T) { + program, err := ParseString(`version: 2.0 + +task "demo": + let $pause_len = "0.05" + wait {$pause_len} seconds +`) + if err != nil { + t.Fatalf("ParseString() error = %v", err) + } + + var out bytes.Buffer + engine := NewEngine(&out) + + start := time.Now() + if err := engine.Execute(program, "demo"); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if elapsed := time.Since(start); elapsed < 50*time.Millisecond { + t.Errorf("expected execution to wait at least 50ms, took %v", elapsed) + } +} + +func TestEngine_WaitRejectsNonNumericValue(t *testing.T) { + program, err := ParseString(`version: 2.0 + +task "demo": + let $pause_len = "abc" + wait {$pause_len} seconds +`) + if err != nil { + t.Fatalf("ParseString() error = %v", err) + } + + var out bytes.Buffer + engine := NewEngine(&out) + + err = engine.Execute(program, "demo") + if err == nil { + t.Fatal("expected error for non-numeric wait duration, got nil") + } + if !strings.Contains(err.Error(), "invalid wait duration") { + t.Errorf("expected invalid duration error, got: %v", err) + } +} diff --git a/internal/lexer/token.go b/internal/lexer/token.go index 086889d..2f1ce16 100644 --- a/internal/lexer/token.go +++ b/internal/lexer/token.go @@ -150,6 +150,18 @@ const ( BE // be EXPECT // expect + // Time unit keywords + SECOND // second + SECONDS // seconds + MINUTE // minute + MINUTES // minutes + HOUR // hour + HOURS // hours + + // Changelog keywords + PROMOTE // promote + CHANGELOG // changelog + // Orchestration keywords ORCHESTRATE // orchestrate SERVICES // services @@ -722,6 +734,22 @@ func (t TokenType) String() string { return "BE" case EXPECT: return "EXPECT" + case SECOND: + return "SECOND" + case SECONDS: + return "SECONDS" + case MINUTE: + return "MINUTE" + case MINUTES: + return "MINUTES" + case HOUR: + return "HOUR" + case HOURS: + return "HOURS" + case PROMOTE: + return "PROMOTE" + case CHANGELOG: + return "CHANGELOG" case ORCHESTRATE: return "ORCHESTRATE" case SERVICES: @@ -1387,6 +1415,14 @@ var keywords = map[string]TokenType{ "at": AT, "be": BE, "expect": EXPECT, + "second": SECOND, + "seconds": SECONDS, + "minute": MINUTE, + "minutes": MINUTES, + "hour": HOUR, + "hours": HOURS, + "promote": PROMOTE, + "changelog": CHANGELOG, "orchestrate": ORCHESTRATE, "services": SERVICES, "strategy": STRATEGY, diff --git a/internal/lsp/hover.go b/internal/lsp/hover.go index fefecae..ce6ca5b 100644 --- a/internal/lsp/hover.go +++ b/internal/lsp/hover.go @@ -22,6 +22,7 @@ var hoverEntries = []hoverEntry{ {"for each", `for each $item in $items:`, "Collection loop", "Runs the nested statements once for every value in a collection."}, {"else if", `else if :`, "Conditional branch", "Adds another condition to the preceding `if` statement."}, {"use workdir", `use workdir "path":`, "Scoped working directory", "Runs the nested statements with a different working directory, then restores the previous directory."}, + {"wait", `wait second(s)|minute(s)|hour(s)`, "Pause execution", "Pauses task execution for a fixed duration, then continues with the next statement. The duration can be a number literal or an interpolated variable. To wait until a service responds instead, use `wait for service at \"url\" to be ready`."}, {"get property", `get property "key" from "file" as $value`, "Read a properties value", "Reads a key from a Java properties file and assigns it to a variable."}, {"get json", `get json "/pointer" from "file" as $value`, "Read a JSON value", "Reads a value selected by JSON Pointer and assigns it to a variable."}, {"get yaml", `get yaml "path" from "file" as $value`, "Read a YAML value", "Reads a value selected by its YAML path and assigns it to a variable."}, @@ -37,6 +38,7 @@ var hoverEntries = []hoverEntry{ {"update yaml", `update yaml "path" in "file" to `, "Update a YAML value", "Rewrites the value selected by its YAML path."}, {"update toml", `update toml "path" in "file" to `, "Update a TOML value", "Rewrites the value selected by its dotted path."}, {"update match", `update match "pattern" in "file" to `, "Update a regular-expression match", "Replaces the selected regular-expression capture."}, + {"promote changelog", `promote changelog "file" to version "X.Y.Z" [on "YYYY-MM-DD"]`, "Promote unreleased changelog entries", "Moves the `## [Unreleased]` section of a Keep a Changelog file into a new dated release section, leaving an emptied Unreleased section behind. When the file has an `[Unreleased]: .../compare/...HEAD` link, the comparison links are updated too. The date defaults to today; use `on \"YYYY-MM-DD\"` to override it. Re-running for a version whose section already exists merges new Unreleased entries into it instead of failing, so release preparation is idempotent."}, {"git policy", `git policy:`, "Git policy", "Defines repository conventions such as branch naming, protected branches, and commit-message rules."}, {"git validate", `git validate`, "Validate Git policy", "Checks the current repository against the configured Git policy."}, {"version", `version: 2.0`, "Language version", "Selects the Drun language version used to parse this file."}, @@ -122,9 +124,21 @@ var hoverExtraExamples = map[string][]string{ "update json": { "update json \"/version\" in \"package.json\" to $version", }, + "promote changelog": { + "promote changelog \"CHANGELOG.md\" to version \"{$release_version}\"", + "promote changelog \"CHANGELOG.md\" to version \"1.5.0\" on \"2026-09-01\"", + "task \"prepare-release\" means \"Prepare a release\":\n requires $version as string matching semver_optional_v\n set $release_version to \"{$version without prefix 'v'}\"\n update json \"/version\" in \"package.json\" to \"{$release_version}\" or fail\n promote changelog \"CHANGELOG.md\" to version \"{$release_version}\"", + }, "use workdir": { "use workdir \"frontend\":\n run \"pnpm test\"", }, + "wait": { + "wait 5 seconds", + "wait {$backoff} minutes", + "wait 1 hour", + "for each $attempt in [\"1\", \"2\", \"3\"]:\n try:\n run \"./flaky-deploy.sh\"\n break\n catch:\n warn \"Attempt {$attempt} failed, backing off\"\n wait {$attempt} minutes", + "wait for service at \"https://api.local/health\" to be ready timeout \"60s\"", + }, "call task": { "call task \"build\"", "call task \"deploy\" with environment=\"staging\"", diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 67b6789..8e21c1f 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -64,7 +64,10 @@ var keywordCompletions = []completionItem{ {Label: "update yaml", Kind: completionItemKindKeyword, Detail: "Update a YAML value"}, {Label: "update toml", Kind: completionItemKindKeyword, Detail: "Update a TOML value"}, {Label: "update match", Kind: completionItemKindKeyword, Detail: "Update a regular-expression capture"}, + {Label: "promote changelog", Kind: completionItemKindKeyword, Detail: "Promote unreleased changelog entries to a release section"}, {Label: "use workdir", Kind: completionItemKindKeyword, Detail: "Change working directory"}, + {Label: "wait", Kind: completionItemKindKeyword, Detail: "Pause execution for a fixed duration"}, + {Label: "wait for service", Kind: completionItemKindKeyword, Detail: "Wait until a service is ready"}, {Label: "call task", Kind: completionItemKindKeyword, Detail: "Call another task"}, {Label: "orchestrate", Kind: completionItemKindKeyword, Detail: "Orchestration definition or action"}, {Label: "service", Kind: completionItemKindKeyword, Detail: "Service definition"}, diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 3d63aee..59aded4 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -162,6 +162,7 @@ func TestHoverCoversCommonStatementsAndIgnoresStringsAndComments(t *testing.T) { {"task", `task "build":`, 1, "Task declaration"}, {"longest phrase", ` call task "build"`, 8, "Call another task"}, {"file value", ` update json "/version" in "package.json" to "2"`, 5, "Update a JSON value"}, + {"changelog promotion", ` promote changelog "CHANGELOG.md" to version "1.5.0"`, 5, "Promote unreleased changelog entries"}, {"control flow", ` for each $item in $items:`, 7, "Collection loop"}, {"tool requirements", ` requires tools:`, 12, "Tool requirements"}, {"unicode column", `é task "build":`, 3, ""}, diff --git a/internal/parser/changelog_test.go b/internal/parser/changelog_test.go new file mode 100644 index 0000000..25a9e02 --- /dev/null +++ b/internal/parser/changelog_test.go @@ -0,0 +1,79 @@ +package parser + +import ( + "testing" + + "github.com/phillarmonic/drun/v2/internal/ast" + "github.com/phillarmonic/drun/v2/internal/lexer" +) + +func TestParser_ChangelogPromotion(t *testing.T) { + tests := []struct { + input string + wantPath string + wantVersion string + wantDate string + }{ + {`promote changelog "CHANGELOG.md" to version "1.5.0"`, "CHANGELOG.md", "1.5.0", ""}, + {`promote changelog "CHANGELOG.md" to version "1.5.0" on "2026-09-01"`, "CHANGELOG.md", "1.5.0", "2026-09-01"}, + {`promote changelog "docs/CHANGELOG.md" to version "{$release_version}"`, "docs/CHANGELOG.md", "{$release_version}", ""}, + } + + for _, tt := range tests { + input := "version: 2.0\n\ntask \"demo\":\n " + tt.input + "\n" + + l := lexer.NewLexer(input) + p := NewParser(l) + program := p.ParseProgram() + + checkParserErrors(t, p) + + if len(program.Tasks) != 1 { + t.Fatalf("%q: program should have 1 task. got=%d", tt.input, len(program.Tasks)) + } + + task := program.Tasks[0] + if len(task.Body) != 1 { + t.Fatalf("%q: task should have 1 statement. got=%d", tt.input, len(task.Body)) + } + + changelogStmt, ok := task.Body[0].(*ast.ChangelogStatement) + if !ok { + t.Fatalf("%q: statement should be ChangelogStatement. got=%T", tt.input, task.Body[0]) + } + + if changelogStmt.Path != tt.wantPath { + t.Errorf("%q: path not %q. got=%q", tt.input, tt.wantPath, changelogStmt.Path) + } + if changelogStmt.Version != tt.wantVersion { + t.Errorf("%q: version not %q. got=%q", tt.input, tt.wantVersion, changelogStmt.Version) + } + if changelogStmt.Date != tt.wantDate { + t.Errorf("%q: date not %q. got=%q", tt.input, tt.wantDate, changelogStmt.Date) + } + } +} + +func TestParser_ChangelogPromotionErrors(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"missing path", `promote changelog to version "1.5.0"`}, + {"missing to version", `promote changelog "CHANGELOG.md"`}, + {"missing version string", `promote changelog "CHANGELOG.md" to version`}, + {"missing on date", `promote changelog "CHANGELOG.md" to version "1.5.0" on`}, + } + + for _, tt := range tests { + input := "version: 2.0\n\ntask \"demo\":\n " + tt.input + "\n" + + l := lexer.NewLexer(input) + p := NewParser(l) + p.ParseProgram() + + if len(p.Errors()) == 0 { + t.Errorf("%s (%q): expected parser errors, got none", tt.name, tt.input) + } + } +} diff --git a/internal/parser/parser_changelog.go b/internal/parser/parser_changelog.go new file mode 100644 index 0000000..2540992 --- /dev/null +++ b/internal/parser/parser_changelog.go @@ -0,0 +1,44 @@ +package parser + +import ( + "github.com/phillarmonic/drun/v2/internal/ast" + "github.com/phillarmonic/drun/v2/internal/lexer" +) + +// isChangelogStatementStart reports whether the current position starts a +// changelog promotion statement: promote changelog ... +func (p *Parser) isChangelogStatementStart() bool { + return p.curToken.Type == lexer.PROMOTE && p.peekToken.Type == lexer.CHANGELOG +} + +// parseChangelogStatement parses a Keep a Changelog promotion: +// +// promote changelog "CHANGELOG.md" to version "1.5.0" +// promote changelog "CHANGELOG.md" to version "{$release_version}" on "2026-09-01" +func (p *Parser) parseChangelogStatement() *ast.ChangelogStatement { + stmt := &ast.ChangelogStatement{Token: p.curToken} + + p.nextToken() // consume 'changelog' + if !p.expectPeek(lexer.STRING) { + return nil + } + stmt.Path = p.curToken.Literal + + if !p.expectPeek(lexer.TO) || !p.expectPeek(lexer.VERSION) { + return nil + } + if !p.expectPeek(lexer.STRING) { + return nil + } + stmt.Version = p.curToken.Literal + + if p.peekToken.Type == lexer.ON { + p.nextToken() // consume 'on' + if !p.expectPeek(lexer.STRING) { + return nil + } + stmt.Date = p.curToken.Literal + } + + return stmt +} diff --git a/internal/parser/parser_control.go b/internal/parser/parser_control.go index a9a03d6..443f7f0 100644 --- a/internal/parser/parser_control.go +++ b/internal/parser/parser_control.go @@ -513,6 +513,12 @@ func (p *Parser) parseControlFlowBody() []ast.Statement { if http != nil { body = append(body, http) } + } else if p.curToken.Type == lexer.WAIT && p.peekToken.Type != lexer.FOR { + // Fixed-duration wait: wait second(s)/minute(s)/hour(s) + wait := p.parseWaitStatement() + if wait != nil { + body = append(body, wait) + } } else if p.isNetworkToken(p.curToken.Type) { network := p.parseNetworkStatement() if network != nil { diff --git a/internal/parser/parser_project.go b/internal/parser/parser_project.go index a9c1c6d..a12410a 100644 --- a/internal/parser/parser_project.go +++ b/internal/parser/parser_project.go @@ -910,6 +910,12 @@ func (p *Parser) parseLifecycleHook() *ast.LifecycleHook { if http != nil { hook.Body = append(hook.Body, http) } + } else if p.curToken.Type == lexer.WAIT && p.peekToken.Type != lexer.FOR { + // Fixed-duration wait: wait second(s)/minute(s)/hour(s) + wait := p.parseWaitStatement() + if wait != nil { + hook.Body = append(hook.Body, wait) + } } else if p.isNetworkToken(p.curToken.Type) { network := p.parseNetworkStatement() if network != nil { diff --git a/internal/parser/parser_task.go b/internal/parser/parser_task.go index 4e4af78..50ab3bc 100644 --- a/internal/parser/parser_task.go +++ b/internal/parser/parser_task.go @@ -203,6 +203,11 @@ func (p *Parser) parseTaskStatement() *ast.TaskStatement { if fileValue != nil { stmt.Body = append(stmt.Body, fileValue) } + } else if p.isChangelogStatementStart() { + changelog := p.parseChangelogStatement() + if changelog != nil { + stmt.Body = append(stmt.Body, changelog) + } } else if p.isDeleteFileStatementStart() { file := p.parseFileStatement() if file != nil { @@ -213,6 +218,12 @@ func (p *Parser) parseTaskStatement() *ast.TaskStatement { if http != nil { stmt.Body = append(stmt.Body, http) } + } else if p.curToken.Type == lexer.WAIT && p.peekToken.Type != lexer.FOR { + // Fixed-duration wait: wait second(s)/minute(s)/hour(s) + wait := p.parseWaitStatement() + if wait != nil { + stmt.Body = append(stmt.Body, wait) + } } else if p.isNetworkToken(p.curToken.Type) { network := p.parseNetworkStatement() if network != nil { diff --git a/internal/parser/parser_wait.go b/internal/parser/parser_wait.go new file mode 100644 index 0000000..25583d8 --- /dev/null +++ b/internal/parser/parser_wait.go @@ -0,0 +1,58 @@ +package parser + +import ( + "github.com/phillarmonic/drun/v2/internal/ast" + "github.com/phillarmonic/drun/v2/internal/lexer" +) + +// parseWaitStatement parses a fixed-duration wait: +// +// wait 5 seconds +// wait {retry_count} minutes +// wait 1 hour +func (p *Parser) parseWaitStatement() *ast.WaitStatement { + stmt := &ast.WaitStatement{Token: p.curToken} + + // Value: a number literal or a {variable} interpolation + switch p.peekToken.Type { + case lexer.NUMBER: + p.nextToken() + stmt.Value = p.curToken.Literal + case lexer.LBRACE: + p.nextToken() // consume { + if p.peekToken.Type != lexer.VARIABLE && p.peekToken.Type != lexer.IDENT { + p.addErrorWithHelpAtPeek("expected variable name in wait duration interpolation", + "use wait {$variable} , e.g. wait {$retries} seconds") + return nil + } + p.nextToken() + stmt.Value = "{" + p.curToken.Literal + "}" + if p.peekToken.Type != lexer.RBRACE { + p.addErrorWithHelpAtPeek("expected } after variable in wait duration", + "use wait {$variable} , e.g. wait {$retries} seconds") + return nil + } + p.nextToken() // consume } + default: + p.addErrorWithHelpAtPeek("expected a number or {$variable} after wait", + "use wait or wait {$variable} , e.g. wait 5 seconds") + return nil + } + + // Unit: second/s, minute/s, hour/s + switch p.peekToken.Type { + case lexer.SECOND, lexer.SECONDS: + stmt.Unit = "second" + case lexer.MINUTE, lexer.MINUTES: + stmt.Unit = "minute" + case lexer.HOUR, lexer.HOURS: + stmt.Unit = "hour" + default: + p.addErrorWithHelpAtPeek("expected a time unit after wait duration", + "use second(s), minute(s), or hour(s), e.g. wait 5 seconds") + return nil + } + p.nextToken() // consume unit + + return stmt +} diff --git a/internal/parser/wait_test.go b/internal/parser/wait_test.go new file mode 100644 index 0000000..cffba4c --- /dev/null +++ b/internal/parser/wait_test.go @@ -0,0 +1,163 @@ +package parser + +import ( + "testing" + + "github.com/phillarmonic/drun/v2/internal/ast" + "github.com/phillarmonic/drun/v2/internal/lexer" +) + +func TestParser_WaitDurationUnits(t *testing.T) { + tests := []struct { + input string + wantValue string + wantUnit string + }{ + {"wait 5 seconds", "5", "second"}, + {"wait 1 second", "1", "second"}, + {"wait 2 minutes", "2", "minute"}, + {"wait 1 minute", "1", "minute"}, + {"wait 3 hours", "3", "hour"}, + {"wait 1 hour", "1", "hour"}, + {"wait 0.5 seconds", "0.5", "second"}, + {"wait {$backoff} seconds", "{$backoff}", "second"}, + {"wait {backoff} minutes", "{backoff}", "minute"}, + } + + for _, tt := range tests { + input := "version: 2.0\n\ntask \"demo\":\n " + tt.input + "\n" + + l := lexer.NewLexer(input) + p := NewParser(l) + program := p.ParseProgram() + + checkParserErrors(t, p) + + if len(program.Tasks) != 1 { + t.Fatalf("%q: program should have 1 task. got=%d", tt.input, len(program.Tasks)) + } + + task := program.Tasks[0] + if len(task.Body) != 1 { + t.Fatalf("%q: task should have 1 statement. got=%d", tt.input, len(task.Body)) + } + + waitStmt, ok := task.Body[0].(*ast.WaitStatement) + if !ok { + t.Fatalf("%q: statement should be WaitStatement. got=%T", tt.input, task.Body[0]) + } + + if waitStmt.Value != tt.wantValue { + t.Errorf("%q: wait value not %q. got=%q", tt.input, tt.wantValue, waitStmt.Value) + } + + if waitStmt.Unit != tt.wantUnit { + t.Errorf("%q: wait unit not %q. got=%q", tt.input, tt.wantUnit, waitStmt.Unit) + } + } +} + +func TestParser_WaitForServiceStillParses(t *testing.T) { + input := `version: 2.0 + +task "demo": + wait for service at "https://api.local/health" to be ready timeout "60s"` + + l := lexer.NewLexer(input) + p := NewParser(l) + program := p.ParseProgram() + + checkParserErrors(t, p) + + task := program.Tasks[0] + if len(task.Body) != 1 { + t.Fatalf("task should have 1 statement. got=%d", len(task.Body)) + } + + networkStmt, ok := task.Body[0].(*ast.NetworkStatement) + if !ok { + t.Fatalf("statement should be NetworkStatement. got=%T", task.Body[0]) + } + + if networkStmt.Action != "wait_for_service" { + t.Errorf("network action not 'wait_for_service'. got=%q", networkStmt.Action) + } +} + +func TestParser_WaitDurationInControlFlow(t *testing.T) { + input := `version: 2.0 + +task "demo": + for each $attempt in ["1", "2"]: + try: + run "./flaky.sh" + break + catch: + wait {$attempt} seconds` + + l := lexer.NewLexer(input) + p := NewParser(l) + program := p.ParseProgram() + + checkParserErrors(t, p) + + task := program.Tasks[0] + if len(task.Body) != 1 { + t.Fatalf("task should have 1 statement. got=%d", len(task.Body)) + } + + loopStmt, ok := task.Body[0].(*ast.LoopStatement) + if !ok { + t.Fatalf("statement should be LoopStatement. got=%T", task.Body[0]) + } + + if len(loopStmt.Body) != 1 { + t.Fatalf("loop body should have 1 statement. got=%d", len(loopStmt.Body)) + } + + tryStmt, ok := loopStmt.Body[0].(*ast.TryStatement) + if !ok { + t.Fatalf("loop body statement should be TryStatement. got=%T", loopStmt.Body[0]) + } + + if len(tryStmt.CatchClauses) != 1 { + t.Fatalf("try should have 1 catch clause. got=%d", len(tryStmt.CatchClauses)) + } + + catchBody := tryStmt.CatchClauses[0].Body + if len(catchBody) != 1 { + t.Fatalf("catch body should have 1 statement. got=%d", len(catchBody)) + } + + waitStmt, ok := catchBody[0].(*ast.WaitStatement) + if !ok { + t.Fatalf("catch body statement should be WaitStatement. got=%T", catchBody[0]) + } + + if waitStmt.Value != "{$attempt}" || waitStmt.Unit != "second" { + t.Errorf("unexpected wait statement. got=%q %q", waitStmt.Value, waitStmt.Unit) + } +} + +func TestParser_WaitDurationErrors(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"missing unit", "wait 5"}, + {"invalid unit", "wait 5 days"}, + {"missing value", "wait seconds"}, + } + + for _, tt := range tests { + input := "version: 2.0\n\ntask \"demo\":\n " + tt.input + "\n" + + l := lexer.NewLexer(input) + p := NewParser(l) + p.ParseProgram() + + if len(p.Errors()) == 0 { + t.Errorf("%s (%q): expected parser errors, got none", tt.name, tt.input) + } + } +}