Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ To be released.

### @optique/core

- Replaced the sentinel-based two-pass `SourceContext` contract with an
explicit `SourceContextRequest` object. `getAnnotations()` and
`getInternalAnnotations()` now receive `phase: "phase1"` / `"phase2"`
requests, so successful first-pass values of `undefined` are no longer
ambiguous. This removes the need for context-local `undefined` wrapping
workarounds and fixes custom two-pass contexts that previously could not
distinguish phase 1 from a real `undefined` parse result. [[#271], [#786]]

- Added the optional `Parser.validateValue()` method, which lets a
parser check whether an arbitrary value satisfies its underlying
`ValueParser`'s constraints (e.g., regex patterns, numeric bounds,
Expand Down Expand Up @@ -171,8 +179,9 @@ To be released.

- `SourceContext.getInternalAnnotations()`: optional method for contexts
to inject additional annotations during collection
- `SourceContext.finalizeParsed()`: optional method for contexts to
transform parsed values before phase-2 annotation collection
- `SourceContextRequest`: explicit phase-1 / phase-2 request object for
`SourceContext.getAnnotations()` and
`SourceContext.getInternalAnnotations()`
- `Parser.shouldDeferCompletion()`: optional method that combinators
(`optional()`, `withDefault()`, `group()`) forward from inner parsers

Expand Down Expand Up @@ -221,7 +230,7 @@ To be released.
same context returned an empty annotation object in phase 2. In two-phase
runs, each context's phase-2 annotation set is now treated as that
context's final snapshot for the second parse pass. Returning `{}` from
`getAnnotations(parsed)` now clears that context's earlier phase-1
phase-two `getAnnotations()` now clears that context's earlier phase-1
contribution instead of letting stale data override later contexts.
[[#231], [#782]]

Expand Down Expand Up @@ -1396,6 +1405,7 @@ To be released.
[#262]: https://github.com/dahlia/optique/issues/262
[#264]: https://github.com/dahlia/optique/issues/264
[#269]: https://github.com/dahlia/optique/issues/269
[#271]: https://github.com/dahlia/optique/issues/271
[#275]: https://github.com/dahlia/optique/issues/275
[#279]: https://github.com/dahlia/optique/issues/279
[#290]: https://github.com/dahlia/optique/issues/290
Expand Down Expand Up @@ -1670,6 +1680,7 @@ To be released.
[#782]: https://github.com/dahlia/optique/pull/782
[#783]: https://github.com/dahlia/optique/pull/783
[#784]: https://github.com/dahlia/optique/pull/784
[#786]: https://github.com/dahlia/optique/pull/786

### @optique/config

Expand Down
134 changes: 88 additions & 46 deletions docs/concepts/extend.md
Original file line number Diff line number Diff line change
Expand Up @@ -730,16 +730,23 @@ API reference
- `id: symbol` — Unique identifier for the context
- `phase: SourceContextPhase` — Required policy declaring whether this
context is `"single-pass"` or `"two-pass"`
- `getAnnotations(parsed?, options?): Promise<Annotations> | Annotations`
- `getAnnotations(request?, options?): Promise<Annotations> | Annotations`
— Returns annotations to inject into parsing
- `getInternalAnnotations?(parsed, annotations): Annotations | undefined`
- `getInternalAnnotations?(request, annotations): Annotations | undefined`
— Optional hook called after `getAnnotations()` to inject additional
internal annotations (e.g., phase-specific markers). Returns
additional annotations to merge, or `undefined` to add nothing.
- `finalizeParsed?(parsed): unknown` — Optional hook to transform the
parsed value before it is passed to `getAnnotations()` during phase-2
annotation collection. This allows contexts to distinguish between
“parsed value was `undefined`” and “no parse happened yet.”

`SourceContextRequest`
: Request object passed to `getAnnotations()` and
`getInternalAnnotations()`. A discriminated union based on the
`phase` field:

- `phase: "phase1"` — Initial annotation collection before the
first parse pass.
- `phase: "phase2"` — Second annotation collection after a usable
first pass. The `parsed` field holds the first-pass value, which
may itself be `undefined`.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Use `ParserValuePlaceholder` in `TRequiredOptions` when the options depend
on the parser's result type.
Expand Down Expand Up @@ -835,7 +842,10 @@ providing annotations to parsers. Each context has:
Here's a simple context that provides environment variables to parsers:

~~~~ typescript
import type { SourceContext } from "@optique/core/context";
import type {
SourceContext,
SourceContextRequest,
} from "@optique/core/context";

const envContext: SourceContext = {
id: Symbol.for("@myapp/env"),
Expand Down Expand Up @@ -872,8 +882,16 @@ Contexts can be either *single-pass* or *two-pass*:
The difference lies in whether `getAnnotations()` needs the parsed result to
do its work:

~~~~ typescript
import type { SourceContext } from "@optique/core/context";
~~~~ typescript twoslash
declare const process: {
readonly env: Record<string, string | undefined>;
};
declare function loadConfigFile(path: string): Promise<unknown>;
// ---cut-before---
import type {
SourceContext,
SourceContextRequest,
} from "@optique/core/context";

// Single-pass context: data is always available
const envContext: SourceContext = {
Expand All @@ -891,14 +909,14 @@ const envContext: SourceContext = {
const configContext: SourceContext = {
id: Symbol.for("@myapp/config"),
phase: "two-pass",
async getAnnotations(parsed?: unknown) {
if (parsed === undefined) return {}; // Return empty on first pass
const result = parsed as { config?: string };
if (!result.config) return {};
async getAnnotations(request?: SourceContextRequest) {
if (request == null || request.phase === "phase1") return {};

const parsed = request.parsed as { config?: string } | undefined;
if (!parsed?.config) return {};

// Load config file asynchronously
const data = await loadConfigFile(result.config);
const data = await loadConfigFile(parsed.config);
return {
[Symbol.for("@myapp/config")]: data
};
Expand All @@ -909,9 +927,8 @@ const configContext: SourceContext = {
The single-pass `envContext` reads environment variables directly and doesn't
need any parsed values. The two-pass `configContext`, however, needs to know
the config file path from the parsed `--config` option before it can load the
file. When `parsed` is `undefined` (phase 1), it can return empty data or a
provisional snapshot; phase 2 still runs because the context explicitly opted
into `"two-pass"`.
file. Phase 1 and phase 2 are explicit through `request.phase`, so a real
phase-two parsed value of `undefined` is no longer ambiguous.


Using `runWith()`
Expand Down Expand Up @@ -989,13 +1006,15 @@ When two-pass contexts are present, `runWith()` automatically performs
two-phase parsing:

1. *Phase 1*: Parse with phase-1 annotations from all contexts
2. *Phase 2*: Call `getAnnotations(parsed)` on two-pass contexts with the
parsed result, then parse again with the merged final annotations
2. *Phase 2*: Call `getAnnotations({ phase: "phase2", parsed })` on
two-pass contexts with the parsed result, then parse again with the
merged final annotations

For each two-pass context, the phase-2 return value replaces that context's
phase-1 annotation set for the final parse. This means returning an empty
object from `getAnnotations(parsed)` clears any annotations the same context
contributed during phase 1. Single-pass contexts keep their phase-1 snapshot.
object from phase-two `getAnnotations()` clears any annotations the same
context contributed during phase 1. Single-pass contexts keep their phase-1
snapshot.

This ensures that:

Expand Down Expand Up @@ -1076,7 +1095,11 @@ accepts a prefix. This pattern allows different applications to use their own
environment variable naming conventions:

~~~~ typescript
import type { SourceContext, Annotations } from "@optique/core/context";
import type {
Annotations,
SourceContext,
SourceContextRequest,
} from "@optique/core/context";

const envKey = Symbol.for("@myapp/env");

Expand Down Expand Up @@ -1115,8 +1138,16 @@ A config file context is two-pass because it needs to know the file path from
parsed arguments. The `getAnnotations()` method receives the parsed result and
uses it to load the configuration:

~~~~ typescript
import type { SourceContext, Annotations } from "@optique/core/context";
~~~~ typescript twoslash
declare const Deno: {
readTextFile(path: string): Promise<string>;
};
// ---cut-before---
import type {
Annotations,
SourceContext,
SourceContextRequest,
} from "@optique/core/context";

const configKey = Symbol.for("@myapp/config");

Expand All @@ -1130,14 +1161,16 @@ export function createConfigContext(): SourceContext {
return {
id: configKey,
phase: "two-pass",
async getAnnotations(parsed?: unknown): Promise<Annotations> {
if (parsed === undefined) return {}; // First pass - no config yet

const result = parsed as { config?: string };
if (!result.config) return {}; // No config file specified
async getAnnotations(
request?: SourceContextRequest,
): Promise<Annotations> {
if (request == null || request.phase === "phase1") return {};

const parsed = request.parsed as { config?: string } | undefined;
if (!parsed?.config) return {}; // No config file specified

try {
const content = await Deno.readTextFile(result.config);
const content = await Deno.readTextFile(parsed.config);
const data: ConfigData = JSON.parse(content);
return { [configKey]: data };
} catch {
Expand All @@ -1151,10 +1184,10 @@ export function createConfigContext(): SourceContext {
const configContext = createConfigContext();
~~~~

Note the defensive checks: when `parsed` is `undefined` (first pass), return
empty. When the user didn't specify `--config`, return empty. When the file
can't be read or parsed, return empty. This ensures the context never throws
and gracefully degrades when config isn't available.
Note the defensive checks: when `request.phase` is `"phase1"`, return empty.
When the user didn't specify `--config`, return empty. When the file can't be
read or parsed, return empty. This ensures the context never throws and
gracefully degrades when config isn't available.

### Creating a type-safe config context

Expand All @@ -1163,11 +1196,16 @@ The example above hardcodes how to extract the config path from parsed results
For a more reusable approach, use `ParserValuePlaceholder` to declare that
the caller must provide a `getConfigPath` function:

~~~~ typescript
~~~~ typescript twoslash
declare const Deno: {
readTextFile(path: string): Promise<string>;
};
// ---cut-before---
import type {
SourceContext,
Annotations,
ParserValuePlaceholder,
SourceContext,
SourceContextRequest,
} from "@optique/core/context";

const configKey = Symbol.for("@myapp/config");
Expand All @@ -1184,19 +1222,24 @@ interface ConfigContextOptions {
}

// The context type includes required options
interface ConfigContext extends SourceContext<ConfigContextOptions> {
getConfigPath?: (parsed: unknown) => string | undefined;
}
type ConfigContext = SourceContext<ConfigContextOptions>;

export function createConfigContext(): ConfigContext {
const context: ConfigContext = {
return {
id: configKey,
phase: "two-pass",
async getAnnotations(parsed?: unknown): Promise<Annotations> {
if (parsed === undefined) return {};

async getAnnotations(
request?: SourceContextRequest,
options?: ConfigContextOptions,
): Promise<Annotations> {
if (request == null || request.phase === "phase1") return {};

const parsed = request.parsed as ParserValuePlaceholder | undefined;

// Use the injected getConfigPath function
const configPath = context.getConfigPath?.(parsed);
const configPath = parsed == null
? undefined
: options?.getConfigPath(parsed);
if (!configPath) return {};

try {
Expand All @@ -1208,7 +1251,6 @@ export function createConfigContext(): ConfigContext {
}
}
};
return context;
}
~~~~

Expand Down
7 changes: 4 additions & 3 deletions docs/concepts/runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -1259,9 +1259,10 @@ When `contexts` is provided, the runner delegates to `runWith()` (or
single-pass and two-pass contexts automatically and performs two-phase parsing
only when needed. In two-phase runs, each two-pass context's phase-two
annotations replace that same context's phase-one contribution for the final
parse, so returning an empty object from `getAnnotations(parsed)` clears that
context's earlier annotations. Context-specific options like `getConfigPath`
are passed through to the contexts via the `contextOptions` property.
parse, so returning an empty object from
`getAnnotations({ phase: "phase2", parsed })` clears that context's earlier
annotations. Context-specific options like `getConfigPath` are passed
through to the contexts via the `contextOptions` property.

For more details on config file integration, see the
[config file integration guide](../integrations/config.md).
Expand Down
5 changes: 4 additions & 1 deletion docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -1397,7 +1397,10 @@ const configContext = createConfigContext({ schema: configSchema });
const args = ["--config", "./config.json"] as const;

const configAnnotations = await configContext.getAnnotations(
{ config: getConfigPathFromArgs(args) },
{
phase: "phase2",
parsed: { config: getConfigPathFromArgs(args) },
},
{ getConfigPath: (parsed: { readonly config?: string }) => parsed.config },
);

Expand Down
13 changes: 8 additions & 5 deletions docs/integrations/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -868,10 +868,12 @@ Returns
: `ConfigContext<T, TConfigMeta>` implementing `SourceContext` interface

> [!IMPORTANT]
> If you call `configContext.getAnnotations()` manually, pass the returned
> object to low-level APIs such as `parse()`, `parseAsync()`,
> `parser.complete()`, `suggest()`, or `getDocPage()`. Calling
> `getAnnotations()` alone does not affect later parses.
> If you call `configContext.getAnnotations()` manually, omit the request for
> a phase-1 snapshot or pass `{ phase: "phase2", parsed }` for a phase-two
> snapshot, then pass the returned object to low-level APIs such as
> `parse()`, `parseAsync()`, `parser.complete()`, `suggest()`, or
> `getDocPage()`. Calling `getAnnotations()` alone does not affect later
> parses.

### `bindConfig(parser, options)`

Expand Down Expand Up @@ -925,7 +927,8 @@ per run, so reusing the same `ConfigContext` instance across independent or
concurrent runs is safe.

When calling `configContext.getAnnotations()` manually, remember that the
call only returns annotations. It does not mutate global state or affect
call only returns annotations. Use `{ phase: "phase2", parsed }` when you
need a manual phase-two snapshot. It does not mutate global state or affect
later parses by itself. To use those values with low-level APIs such as
`parse()` or `suggestSync()`, pass the returned annotations explicitly.

Expand Down
5 changes: 3 additions & 2 deletions docs/integrations/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,8 +468,9 @@ Returns
> [!IMPORTANT]
> If you call `envContext.getAnnotations()` manually, pass the returned
> object to low-level APIs such as `parse()`, `parseAsync()`,
> `parser.complete()`, `suggest()`, or `getDocPage()`. Calling
> `getAnnotations()` alone does not affect later parses.
> `parser.complete()`, `suggest()`, or `getDocPage()`. Environment contexts
> are single-pass, so calling `getAnnotations()` without a request still
> reads the final snapshot. Calling it alone does not affect later parses.

### `bindEnv(parser, options)`

Expand Down
Loading
Loading