Skip to content

Commit b55fcdf

Browse files
Smaug123claude
andauthored
ArgParser: reject leaf-only attributes on structural fields (#606)
* ArgParser: reject leaf-only attributes on structural fields A field whose type is another argument record, or a union of alternative argument sets, contributes that type's whole set of arguments rather than one. [<PositionalArgs>], [<ParseExact>], [<InvariantCulture>] and [<ArgumentNegateWithPrefix>] each describe how a single argument is collected, spelled or read, so there is nothing here for them to act on -- and the structural branches take over before the leaf machinery which reads them ever runs, so each was computed and then dropped on the floor. [<ArgumentHelpText>] is deliberately not in the list: on a structural field it introduces the group of arguments the field contributes. Breaking; lands under the 11.0 bump carried by the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ArgParser: reject [<ArgumentNegateWithPrefix>] on positional fields (#607) * ArgParser: reject [<ArgumentNegateWithPrefix>] on positional fields `hasNegateAttr` was computed inside the non-positional leaf branch only. The positional branch never looked at it and hardcoded `AcceptsNegation = false`, so the attribute was silently ignored. A positional field is not spelled at all -- it collects whatever carries no name -- so there is no name from which a --no- variant could be formed, whatever the field's type. That is a different complaint from the existing boolean-shape check, and gets its own message. The extraction moves above the positional split so both sides read one definition. Breaking; lands under the 11.0 bump carried earlier in this stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Correct the rejection message: a positional sink does have keyed forms A [<PositionalArgs>] field is addressable -- `--rest value` and `--rest=value` route to the sink, and an explicit [<ArgumentLongForm>] can add further spellings -- so claiming it "has no spelling to negate" contradicted the parser's own behaviour. The rejection stands; the reason is that those keyed forms are value-taking routing keys, so a sink which accumulates values has no boolean for a `--no-` form to invert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ArgParser: reject [<ParseExact>] and [<InvariantCulture>] where they are not read (#608) * ArgParser: reject [<ParseExact>] and [<InvariantCulture>] where unread Both are read in exactly one place: the TimeSpan arm of createParseFunction. On any other type they were dropped -- and for [<ParseExact>] that is worse than silence, because `helpText` advertises the format unconditionally, so the generated --help promised a format the generated parser did not honour. Checked against the type actually handed to the parser rather than the declared field type, exactly as checkSeparatorAttributesPlacement is, so `TimeSpan option` and `TimeSpan list` keep working. Departure from the planned scope: the plan called for rejecting maps outright, on the belief that only the value type sees the attribute. It does not -- the map branch hands the field's attributes to its key parser as well -- so `Map<string, TimeSpan>` and `Map<TimeSpan, string>` both genuinely honour the attribute today, and rejecting them would have removed working behaviour. A map is therefore rejected only when neither component is a TimeSpan. Also noted while testing: System.DateTime, the type an author would most likely want a parse format for, is not supported by the generator at all and fails earlier with its own message. There is no lying help to fix there; the lie is on `string`, `int` and friends. Breaking; lands under the 11.0 bump carried earlier in this stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Document ArgParser attribute placement, and the 11.0 breaking changes (#609) Attribute doc comments now state where each attribute is read and where it is rejected. Two were actively misleading: - ArgumentFlagAttribute said it went on "a field of a two-case no-data discriminated union". It goes on the union's *cases*; the loose wording invited precisely the mistake now rejected. - ParseExactAttribute and InvariantCultureAttribute both wrote the attribute as [<ArgumentParseExact>], which does not exist, and neither said it is honoured on TimeSpan alone. README gains an "attribute placement at a structural boundary" section, and no longer claims [<ParseExact>] works on "TimeSpan and friends". CHANGELOG records 11.0 as one entry covering the whole rejection stack, plus the backtick fix which landed after 10.7.2 and was never published. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 235763f commit b55fcdf

5 files changed

Lines changed: 597 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
Notable changes are recorded here.
22

3+
# WoofWare.Myriad.Plugins 11.0.1
4+
5+
Breaking change: `ArgParserGenerator` now rejects, at generation time, several attribute placements which it previously accepted and then silently ignored.
6+
7+
* `[<ArgumentFlag>]` on a record field. It belongs on the two cases of a flag discriminated union, where it says which case means `true` and which means `false`; a field is not where that question can be answered.
8+
* `[<PositionalArgs>]`, `[<ParseExact>]`, `[<InvariantCulture>]` or `[<ArgumentNegateWithPrefix>]` on a field whose type is another argument record, or a union of alternative argument sets. Such a field contributes a whole set of arguments rather than one, so there is no single argument for these to collect into, spell or negate. `[<ArgumentHelpText>]` is deliberately still accepted there: it heads the group of arguments the field contributes.
9+
* `[<ArgumentNegateWithPrefix>]` on a `[<PositionalArgs>]` field. A positional field is a sink which accumulates values; its keyed spellings take a value rather than setting anything, so there is no boolean there for a `--no-` form to invert.
10+
* `[<ParseExact>]` or `[<InvariantCulture>]` on anything but a `System.TimeSpan`. These are read only by the `TimeSpan` parser. They continue to reach through the shapes which wrap a value — `TimeSpan option`, `TimeSpan list`, `Choice<TimeSpan, TimeSpan>`, and a `Map` either of whose halves is a `TimeSpan` — and are rejected only where no `TimeSpan` is reached at all. Future work may expand the range of types these attributes affect.
11+
312
# WoofWare.Myriad.Plugins 10.7.2
413

514
`ArgParserGenerator` now reads `[<ArgumentHelpText>]` from a nested argument record or union of alternative argument sets, and not only from the `[<ArgParser>]`-tagged root, and from a discriminated union's case (and that case's payload record).

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ and you get back respectively these objects:
243243
}
244244
```
245245

246-
You can control `TimeSpan` and friends with the `[<InvariantCulture>]` and `[<ParseExact @"hh\:mm\:ss">]` attributes.
246+
You can control how a `System.TimeSpan` is read with the `[<InvariantCulture>]` and `[<ParseExact @"hh\:mm\:ss">]` attributes.
247+
These are honoured on `TimeSpan` only — reaching through the shapes which wrap it, so `TimeSpan option`, `TimeSpan list` and a `Map` with a `TimeSpan` half are all covered — and are rejected on any other type, where nothing would read them.
247248

248249
You can generate extension methods for the type, instead of a module with the type's name, using `[<ArgParser (* isExtensionMethod = *) true>]`.
249250

@@ -379,6 +380,18 @@ Discriminated unions compose with each other and with records, hopefully as you
379380
We will fail at build time to generate a parser if you have several DU cases which contain fields of the same name, though, because in general resolving the parse in that case is NP-hard.
380381
(An upcoming piece of work will hopefully relax this restriction.)
381382

383+
#### Attribute placement at a structural boundary
384+
385+
A field whose type is another argument record, or a union of alternative argument sets, is *structural*: it contributes that type's whole set of arguments rather than being one argument itself.
386+
Most attributes describe single arguments, so are rejected at build time when applied to a structural field.
387+
388+
Two attributes do apply to structural fields, because they are specifically about the group:
389+
390+
* `[<ArgumentHelpText>]` describes the whole group of arguments the field contributes, and appears on the header line introducing it.
391+
* `[<ArgumentPrefix>]` namespaces every argument in the subtree; conversely, it is rejected on a leaf, which has no subtree.
392+
393+
`[<ArgumentFlag>]` is not a field attribute at all: it goes on the two cases of a flag discriminated union, and is rejected on a record field.
394+
382395
### What's the point?
383396

384397
I got fed up of waiting for us to find time to rewrite the in-house one at work.

WoofWare.Myriad.Plugins.Attributes/ArgParserAttributes.fs

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ type ArgParserAttribute (isExtensionMethod : bool) =
3131
/// tell you whether each arg came before or after a standalone `--` separator.
3232
/// For example, `MyApp foo bar -- baz` with PositionalArgs of `Choice<string, string>`
3333
/// would yield `Choice1Of2 foo, Choice1Of2 bar, Choice2Of2 baz`.
34+
///
35+
/// This attribute applies to an argument *leaf*. It is rejected on a field whose type is another
36+
/// [<ArgParser>]-schema record or a discriminated union of alternative argument sets: such a field
37+
/// contributes a whole set of arguments rather than being a place values can be collected into, so
38+
/// put the attribute on the leaf field inside that type which should do the collecting.
39+
///
40+
/// A positional field is a sink which accumulates values. It is addressable: `--foo value` and
41+
/// `--foo=value` route to it, as does any explicit [<ArgumentLongForm>]. But those keyed forms
42+
/// take a value rather than setting anything, so the field is rejected in combination with
43+
/// [<ArgumentNegateWithPrefix>] (there is no boolean for a `--no-` form to invert) and with
44+
/// [<ArgumentPrefix>] (there is no subtree to namespace), and it may not carry a default
45+
/// (positional args are collected, not defaulted).
3446
type PositionalArgsAttribute (includeFlagLike : bool) =
3547
inherit Attribute ()
3648

@@ -102,25 +114,42 @@ type ArgumentDefaultValueAttribute (defaultValue : obj) =
102114
type ArgumentHelpTextAttribute (helpText : string) =
103115
inherit Attribute ()
104116

105-
/// Attribute indicating that this field should be parsed with a ParseExact method on its type.
106-
/// For example, on a TimeSpan field, with [<ArgumentParseExact @"hh\:mm\:ss">], we will call
107-
/// `TimeSpan.ParseExact (s, @"hh\:mm\:ss", CultureInfo.CurrentCulture).
117+
/// Attribute giving the format in which this field's value is written.
118+
/// For example, on a TimeSpan field, with [<ParseExact @"hh\:mm\:ss">], we will call
119+
/// `TimeSpan.ParseExact (s, @"hh\:mm\:ss", CultureInfo.CurrentCulture)`.
120+
///
121+
/// This attribute is honoured on `System.TimeSpan` only, and is rejected anywhere else: no other
122+
/// type's parser reads it, so it would otherwise be dropped in silence while the generated help
123+
/// text went on advertising a format the parser did not apply.
124+
///
125+
/// It reaches through the shapes which wrap a value, since those parse their components with it:
126+
/// `TimeSpan option`, `TimeSpan list` and `Choice<TimeSpan, TimeSpan>` are all honoured, as is a
127+
/// `Map` either of whose halves is a `TimeSpan` (in which case the format describes each `TimeSpan`
128+
/// half; a map with no `TimeSpan` at all is rejected).
108129
type ParseExactAttribute (format : string) =
109130
inherit Attribute ()
110131

111132
/// Attribute indicating that this field should be parsed in the invariant culture, rather than the
112133
/// default current culture.
113-
/// For example, on a TimeSpan field, with [<InvariantCulture>] and [<ArgumentParseExact @"hh\:mm\:ss">], we will call
114-
/// `TimeSpan.ParseExact (s, @"hh\:mm\:ss", CultureInfo.InvariantCulture).
134+
/// For example, on a TimeSpan field, with [<InvariantCulture>] and [<ParseExact @"hh\:mm\:ss">], we will call
135+
/// `TimeSpan.ParseExact (s, @"hh\:mm\:ss", CultureInfo.InvariantCulture)`.
136+
///
137+
/// As with [<ParseExact>], this is honoured on `System.TimeSpan` only -- reaching through option,
138+
/// list, choice and map in the same way -- and is rejected anywhere else, where nothing would read
139+
/// it.
115140
type InvariantCultureAttribute () =
116141
inherit Attribute ()
117142

118-
/// Attribute placed on a field of a two-case no-data discriminated union, indicating that this is "basically a bool".
143+
/// Attribute placed on each case of a two-case no-data discriminated union, indicating that the
144+
/// union is "basically a bool".
119145
/// For example: `type DryRun = | [<ArgumentFlag true>] Dry | [<ArgumentFlag false>] Wet`
120146
/// A record with `{ DryRun : DryRun }` will then be parsed like `{ DryRun : bool }` (so the user supplies `--dry-run`),
121147
/// but that you get this strongly-typed value directly in the code (so you `match args.DryRun with | DryRun.Dry ...`).
122148
///
123149
/// You must put this attribute on both cases of the discriminated union, with opposite values in each case.
150+
///
151+
/// It belongs on the union's *cases*, and is rejected on a record field: a field is not where the
152+
/// question "which case means true?" can be answered, and nothing read the attribute there.
124153
type ArgumentFlagAttribute (flagValue : bool) =
125154
inherit Attribute ()
126155

@@ -132,6 +161,12 @@ type ArgumentFlagAttribute (flagValue : bool) =
132161
/// You can place this argument multiple times.
133162
///
134163
/// Omit the initial `--` that you expect the user to type.
164+
///
165+
/// This attribute applies to an argument *leaf*. It is rejected on a field whose type is another
166+
/// [<ArgParser>]-schema record or a discriminated union of alternative argument sets: such a field
167+
/// contributes a whole set of arguments, each named by its own field, so there is no single
168+
/// argument here to rename. Put the attribute on the field you mean to rename, or use
169+
/// [<ArgumentPrefix>] to rename the whole subtree at once.
135170
[<AttributeUsage(AttributeTargets.Field, AllowMultiple = true)>]
136171
type ArgumentLongForm (s : string) =
137172
inherit Attribute ()
@@ -215,6 +250,12 @@ type ArgumentMapEntrySeparatorAttribute (separator : char) =
215250
/// --no-field-name=false sets to the [<ArgumentFlag true>] case
216251
///
217252
/// This attribute can only be applied to bool fields or flag DU fields (two-case DUs with [<ArgumentFlag>]).
253+
///
254+
/// It applies to an argument *leaf*, and to a leaf which has a name. So it is also rejected on a
255+
/// field whose type is another [<ArgParser>]-schema record or a discriminated union of alternative
256+
/// argument sets (which contributes a whole set of arguments, none of them singled out for
257+
/// negation), and on a [<PositionalArgs>] field (which accumulates values, so although it is
258+
/// addressable, there is no boolean there for a `--no-` form to invert).
218259
[<AttributeUsage(AttributeTargets.Field, AllowMultiple = false)>]
219260
type ArgumentNegateWithPrefixAttribute () =
220261
inherit Attribute ()

0 commit comments

Comments
 (0)