Skip to content

Add the portable option and its parser-level restrictions#812

Merged
magicant merged 14 commits into
masterfrom
portable-option
Jun 29, 2026
Merged

Add the portable option and its parser-level restrictions#812
magicant merged 14 commits into
masterfrom
portable-option

Conversation

@magicant

@magicant magicant commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Description

Implements the portable shell option. This PR is part of #807.

  • Reject ;;& and ;| terminators in the case command
  • Reject { { :; } >foo } and { ( : ) }
  • Reject non-portable redirection operators
  • Reject IO_NUMBER or IO_LOCATION tokens appearing as a redirection operand
  • Reject non-portable escapes
  • Reject !( and (( as the beginning of a command

Items not addressed in this PR

  • Reject assignment with non-portable names
  • Ignore aliases with non-portable names
  • Ignore environment variables that do not have a portable name in initialization
  • Reject creation of variables with non-portable names in variable-managing built-ins (e.g. export and read)
  • Reject the {n} prefix in redirection
  • Reject invalid names in for loops
  • Reject invalid names in function definitions
  • Reject command names ending with a :
  • Reject POSIXly unspecified combinations of name and modifier in parameter expansion
  • Reject the increment and decrement operators in arithmetic expansion
  • Reject making the PWD, OLDPWD, OPTIND, OPTARG, and LINENO variables readonly
  • Reject elective and extension built-ins
  • Reject source as an alias for the . built-in
  • Reject long options in built-ins
  • Disable array support
  • Error out when the unset built-in is invoked without an operand
  • Ignore non-portable shell initialization files

Checklist

  • Code follows the existing style and conventions
  • Unit tests are added in the same file as the code being tested
  • If the change affects observable behavior of the shell executable, scripted tests are added or updated (yash-cli/tests/scripted_test.rs)

Summary by CodeRabbit

  • New Features
    • Added a new portable shell option to restrict scripts to portable syntax.
    • Portable mode now enforces portability rules across parsing (including case terminators, grouping/subshell constructs, pipelines/negated subshells, redirections, and certain escapes).
  • Bug Fixes
    • Improved detection and reporting of non-portable syntax with clearer, portable-mode-specific errors.
  • Documentation
    • Expanded “portable” option coverage in shell options and portability guidance, with updated examples and compatibility notes.
  • Tests
    • Added/updated scripted and unit tests to verify portable acceptance/rejection behavior across affected constructs.

magicant and others added 2 commits June 21, 2026 23:53
Introduce the new `Portable` shell option, intended to disable
non-portable features of the shell. The option is recognized by the
option parser and listed by the set built-in, but has no effect yet;
behavior will be added incrementally per #807.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
List the new `portable` option in the shell-option reference, the
`set -o`/`set +o` examples, and the topic index. Add a "Writing portable
scripts" section to the POSIX compliance page explaining the option's
purpose and how it differs from `posixlycorrect`. The option has no
effect yet; the affected behaviors will be listed as they are added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@magicant magicant self-assigned this Jun 21, 2026
magicant and others added 11 commits June 24, 2026 23:41
Introduce the infrastructure that lets the parser see option states that
affect which syntax is accepted, so that per-item rejections for the
portable option can be added later (#807).

- yash-env: add `parser::Mode` (currently a single `portable` flag,
  created from an `OptionSet` via `From`) and a `mode` field on
  `parser::Config`.
- yash-syntax: store the mode in the lexer and expose `Lexer::mode` and
  `Lexer::set_mode`. Both lexer-level and parser-level code can read it
  through the lexer.
- yash-semantics: refresh the lexer's mode from the current options
  before parsing each command line, so changes via the `set` built-in
  take effect on subsequent input.

The mode is plumbed end to end but not yet consulted, so parsing behavior
is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the lexer's parsing mode has `portable` enabled, the case command
parser now rejects the `;;&` and `;|` terminators (both parsed as
`CaseContinuation::Continue`), which are extensions not portable across
POSIX-conforming shells. The portable `;;` and `;&` terminators remain
accepted.

This is the first behavior driven by the `portable` shell option (#807),
exercising the parsing-mode plumbing end to end: `set -o portable` on one
command line takes effect when the following command line is parsed.

- yash-syntax: add `SyntaxError::NonPortableCaseTerminator` and the
  rejection in the case parser, reading the mode via a new internal
  `Parser::mode` accessor.
- yash-cli: document the option's first effect (bumped to 3.3.0) and add
  scripted tests.
- docs: list the rejection under "Writing portable scripts" and note it
  on the case command and option pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the lexer's parsing mode has `portable` enabled, the redirection
parser now rejects two non-portable constructs (#807):

- The `>>|` and `<<<` operators, which are extensions not portable
  across POSIX-conforming shells (`SyntaxError::NonPortableRedirOperator`).
- An `IO_NUMBER` or `IO_LOCATION` token used as a redirection operand,
  i.e. a number or `{...}` immediately followed by a redirection
  operator (`SyntaxError::NonPortableRedirOperand`). The error message
  advises adding a space so the token is parsed as a plain word.

The standard operators and blank-separated operands remain accepted.
The `{n}` redirection prefix is already rejected unconditionally because
the I/O location feature is unimplemented, so it needs no change here.

Also renames the lexer test `lexer_token_digit_not_followed_by_less_or_greater`
to `lexer_token_digit_followed_by_semicolon` and adds
`lexer_token_digit_followed_by_blank`, covering the (portable-independent)
rule that a blank prevents IO_NUMBER recognition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Under the portable option, the compound command parser now rejects a
clause-delimiting reserved word (`}`, `done`, `fi`, `then`, `elif`,
`else`, `esac`, `do`) that immediately follows a subshell or a
redirection without a separator, as in `{ ( : ) }` or `{ { :; } >foo }`
(#807).

POSIX recognizes a reserved word only when it is the first word of a
command or follows another reserved word. The lexer tags every keyword
token with which reserved word it would be, but whether the token is
actually treated as a reserved word depends on its syntactic position,
which only the parser knows. The check therefore lives in
`full_compound_command`, right after the compound command and its
redirections are parsed: a subshell ends with `)` and a redirection ends
with a word, so a reserved word that follows one (without a separator) is
not portably recognized. A reserved word after another reserved word
(`{ { :; } }`, `{ if ...; fi }`) and constructs with a separator remain
accepted, as does everything without the portable option.

The error uses a new `SyntaxError::MissingSeparatorBeforeReservedWord`
rather than reusing `MissingSeparator`: this is essentially a special
case of a missing separator (were the shell always in portable mode it
would simply be one), but a dedicated variant lets the message state that
the restriction comes from the portable option while the label still
points out that a separator is missing.

- yash-syntax: add the variant and the rejection (read via `Parser::mode`).
- yash-cli: document the option's new effect (3.3.0) and add scripted
  tests (grouping-y.sh), including portable-off counterparts.
- docs: explain reserved-word recognition and the extension centrally in
  the keywords page, linked from the grouping and POSIX-compliance pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The escapes \E, \?, \u, \U, and \c@ are yash extensions that POSIX does
not define for $'...', and more than two hexadecimal digits after \x is
left unspecified by POSIX. With the portable option enabled, reject these
with SyntaxError::NonPortableEscape and SyntaxError::TooLongHexEscape
respectively.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Other shells parse `((...))` as an arithmetic command and `!(...)` as an
extended glob, neither of which yash-rs implements. yash-rs parses them as
nested subshells and a negated subshell, but the form is non-portable. With
the portable option enabled, reject `((` and `!(` at the beginning of a
command with SyntaxError::UnsupportedArithmeticCommand and
SyntaxError::UnsupportedExtendedGlob respectively. A separating space (`( (`
or `! (`) remains portable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous name was easily confused with the sibling
`NonPortableRedirOperator` and implied the operand itself was
non-portable. The error actually fires when a token that should be a
redirection operand is lexed as an `IO_NUMBER` or `IO_LOCATION` token,
so the new name names that condition directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous message ("a redirection operand immediately followed by a
redirection operator is not portable") implied that any operand abutting
a redirection operator is non-portable, which is not the case. The new
message instead explains the situation to the user: the operand is
effectively missing because the token binds to the next redirection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Errors raised because the `portable` option is enabled now carry a
`note:` footnote explaining that the construct is rejected only because
the option is on. Several such errors (e.g. `UnsupportedArithmeticCommand`
rendering as "unsupported syntax", `IoTokenAsRedirOperand`, and
`NonPortableEscape`) previously gave no hint that disabling the option
would accept the input.

Add `SyntaxError::footnotes` (and a delegating `ErrorCause::footnotes`),
which returns the supplementary footnotes for an error as a slice of
`(FootnoteType, &str)` pairs. `Error::to_report` now appends these to the
report, so the portable note and the pre-existing `BangAfterBar`
suggestion both flow through one data-driven path instead of ad-hoc
special cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Now that portable-option errors carry a `note:` footnote explaining the
`portable` option caused them, the messages and labels no longer need to
repeat "in portable mode". Reword them to describe the offending syntax
instead:

- Labels state why the construct is non-portable (e.g. "`;;&` is not a
  POSIX case terminator") rather than "cannot be used in portable mode".
- `UnsupportedArithmeticCommand` and `UnsupportedExtendedGlob` get their
  own messages explaining the `((`/`!(` ambiguity instead of the generic
  "unsupported syntax" shared with truly unsupported constructs, and their
  labels point out how other shells read the token.
- `MissingSeparatorBeforeReservedWord` drops the "the portable option
  does not allow ..." phrasing for "a separator is missing before the
  reserved word".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@magicant magicant marked this pull request as ready for review June 28, 2026 15:54
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds the portable shell option and propagates it through parser mode, lexer state, parser rejections, runtime mode refresh, tests, and documentation.

Changes

portable Shell Option

Layer / File(s) Summary
Portable option and parser mode
yash-env/src/option.rs, yash-env/src/parser.rs, yash-env/Cargo.toml, yash-env/CHANGELOG.md, Cargo.toml
Adds Option::Portable, wires its name lookup, introduces parser::Mode { portable: bool }, adds mode: Mode to parser::Config, and updates related version/changelog entries.
Lexer mode storage and access
yash-syntax/src/parser/lex/core.rs, yash-syntax/src/parser/core.rs, yash-syntax/src/parser/lex/token.rs, yash-syntax/Cargo.toml, yash-syntax/CHANGELOG.md
Stores Mode in LexerCore, propagates it from Config, exposes Lexer::mode() and Lexer::set_mode(), adds Parser::mode(), and updates lexer-mode tests and release notes.
Portable syntax errors and footnotes
yash-syntax/src/parser/error.rs
Adds portable-related SyntaxError variants, extends messages and labels, centralizes footnotes, adds ErrorCause::footnotes(), and updates error-report tests.
Portable-mode parser rejections
yash-syntax/src/parser/case.rs, yash-syntax/src/parser/redir.rs, yash-syntax/src/parser/compound_command.rs, yash-syntax/src/parser/grouping.rs, yash-syntax/src/parser/pipeline.rs, yash-syntax/src/parser/lex/escape.rs
Enforces portable-mode checks in case terminators, redirections, compound commands, subshells, pipelines, and dollar-single-quote escapes, with matching parser tests.
Read-eval loop mode refresh
yash-semantics/src/runner.rs, yash-semantics/Cargo.toml, yash-semantics/CHANGELOG.md
Updates lexer mode from current shell options before each parse iteration so later commands use the latest parsing restrictions, with the related changelog and version bump.
Scripted integration coverage
yash-cli/tests/scripted_test.rs, yash-cli/tests/scripted_test/case-y.sh, yash-cli/tests/scripted_test/grouping-y.sh, yash-cli/tests/scripted_test/quote-y.sh, yash-cli/tests/scripted_test/redir-y.sh, yash-builtin/src/set.rs
Adds scripted tests for portable-mode case terminators, grouping constructs, redirection forms, and dollar-single-quote escapes, and updates the set -o output test.
Documentation and release notes
docs/src/posix.md, docs/src/environment/options.md, docs/src/language/commands/*, docs/src/language/words/*, docs/src/topic_index.md, yash-cli/CHANGELOG.md, yash-cli/Cargo.toml
Documents the portable option and portable-script guidance across manuals and index pages, and updates crate changelogs and version metadata.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • magicant/yash-rs#430: Adds support for ;;&/;| case terminators that this PR later gates in portable mode.
  • magicant/yash-rs#503: Changes redirection parsing around IoLocation, which this PR extends with portable-mode rejection.
  • magicant/yash-rs#649: Introduces yash_env::parser::Config plumbing that this PR extends with mode: Mode.

Suggested labels

enhancement

🐇 I hop through scripts with careful feet,
portable paths and branches now meet.
set -o portable keeps the lines in sight,
and non-POSIX moonbeams fade out of night. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description covers the change, checklist, and out-of-scope items, matching the template well.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: adding the portable option and enforcing parser-level restrictions.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch portable-option

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
yash-cli/tests/scripted_test/redir-y.sh (1)

18-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the non-portable acceptance counterpart for IO_LOCATION operands.

Lines 18-20 only prove the portable-mode failure. Without a matching success case, an unconditional rejection of < {n}>/dev/null would still pass this file. Please mirror the IO_NUMBER pair with a plain-mode test that creates '{n}' first and then runs : < {n}>/dev/null.

As per coding guidelines, yash-cli/tests/scripted_test/**: For shell-observable behavior changes, add or update scripted tests under yash-cli/tests/scripted_test/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@yash-cli/tests/scripted_test/redir-y.sh` around lines 18 - 26, Add the
plain-mode success counterpart for IO_LOCATION handling in the scripted
redirection tests. The current `test_O` case in `redir-y.sh` only covers
portable rejection of `< {n}>/dev/null`, so add a matching non-portable
`test_OE` scenario that first creates `'{n}'` and then runs `: < {n}>/dev/null`
to confirm it is accepted outside portable mode. Mirror the existing `IO_NUMBER`
test pair structure so the behavior is covered by both failure and success
cases.

Source: Coding guidelines

yash-cli/tests/scripted_test/quote-y.sh (1)

23-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a \u portable-mode case here.

portable now rejects both \u and \U, but this fixture only exercises \U. A regression that leaves \u accepted would still pass, so please add a sibling test_O -d -e 2 ... $'\u0041' case next to these escape checks.

As per coding guidelines, yash-cli/tests/scripted_test/**: For shell-observable behavior changes, add or update scripted tests under yash-cli/tests/scripted_test/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@yash-cli/tests/scripted_test/quote-y.sh` around lines 23 - 34, Add a
portable-mode scripted test case for the \u escape alongside the existing escape
checks in quote-y.sh. The current fixture covers \c@, \E, and \U via test_O, but
it does not exercise \u, so portable acceptance of \u could regress unnoticed.
Insert a sibling test_O case using the same pattern and expected failure
behavior as the other portable rejection cases, near the existing escape
assertions in the scripted test.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/src/posix.md`:
- Around line 28-30: The redirection example in the POSIX docs is misleading:
the current “< 1>file” wording does not match how the parser tokenizes
redirections. Update the example in the posix.md content to a case that actually
demonstrates the rejected pattern, such as an IO_NUMBER or {…} token immediately
followed by a redirection operator, and make sure the wording matches the parser
behavior used by the redirection and case-command docs referenced in this
section.

In `@yash-env/Cargo.toml`:
- Line 3: The version for yash-env needs to be bumped as a breaking pre-1.0
release because the public API changes in Option::Portable and
parser::Config.mode can break downstream compilation. Update the yash-env
version in Cargo.toml to 0.16.0, and make sure the corresponding changelog entry
matches that breaking-release version. Also check any workspace dependency
version references that need to stay in sync with yash-env.

---

Nitpick comments:
In `@yash-cli/tests/scripted_test/quote-y.sh`:
- Around line 23-34: Add a portable-mode scripted test case for the \u escape
alongside the existing escape checks in quote-y.sh. The current fixture covers
\c@, \E, and \U via test_O, but it does not exercise \u, so portable acceptance
of \u could regress unnoticed. Insert a sibling test_O case using the same
pattern and expected failure behavior as the other portable rejection cases,
near the existing escape assertions in the scripted test.

In `@yash-cli/tests/scripted_test/redir-y.sh`:
- Around line 18-26: Add the plain-mode success counterpart for IO_LOCATION
handling in the scripted redirection tests. The current `test_O` case in
`redir-y.sh` only covers portable rejection of `< {n}>/dev/null`, so add a
matching non-portable `test_OE` scenario that first creates `'{n}'` and then
runs `: < {n}>/dev/null` to confirm it is accepted outside portable mode. Mirror
the existing `IO_NUMBER` test pair structure so the behavior is covered by both
failure and success cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a01115fe-f9a5-4cb2-ab98-a61fb76b6f4c

📥 Commits

Reviewing files that changed from the base of the PR and between 11a251e and 1f416ca.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (36)
  • Cargo.toml
  • docs/src/environment/options.md
  • docs/src/language/commands/case.md
  • docs/src/language/commands/grouping.md
  • docs/src/language/commands/pipelines.md
  • docs/src/language/words/keywords.md
  • docs/src/language/words/quoting.md
  • docs/src/posix.md
  • docs/src/topic_index.md
  • yash-builtin/src/set.rs
  • yash-cli/CHANGELOG.md
  • yash-cli/Cargo.toml
  • yash-cli/tests/scripted_test.rs
  • yash-cli/tests/scripted_test/case-y.sh
  • yash-cli/tests/scripted_test/grouping-y.sh
  • yash-cli/tests/scripted_test/quote-y.sh
  • yash-cli/tests/scripted_test/redir-y.sh
  • yash-env/CHANGELOG.md
  • yash-env/Cargo.toml
  • yash-env/src/option.rs
  • yash-env/src/parser.rs
  • yash-semantics/CHANGELOG.md
  • yash-semantics/Cargo.toml
  • yash-semantics/src/runner.rs
  • yash-syntax/CHANGELOG.md
  • yash-syntax/Cargo.toml
  • yash-syntax/src/parser/case.rs
  • yash-syntax/src/parser/compound_command.rs
  • yash-syntax/src/parser/core.rs
  • yash-syntax/src/parser/error.rs
  • yash-syntax/src/parser/grouping.rs
  • yash-syntax/src/parser/lex/core.rs
  • yash-syntax/src/parser/lex/escape.rs
  • yash-syntax/src/parser/lex/token.rs
  • yash-syntax/src/parser/pipeline.rs
  • yash-syntax/src/parser/redir.rs

Comment thread docs/src/posix.md
Comment thread yash-env/Cargo.toml
Mirror the existing IO_NUMBER coverage so the non-portable acceptance of
an IO_LOCATION token used as a redirection operand is guarded too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@magicant magicant changed the title Portable option Add the portable option and its parser-level restrictions Jun 29, 2026
@magicant magicant merged commit 3c70deb into master Jun 29, 2026
14 checks passed
@magicant magicant deleted the portable-option branch June 29, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant