Skip to content

Commit 48b1438

Browse files
committed
feat(argv): add schema-checked ordered options and immutable request inputs
Observed second RED at d02b16d: 2038 tests, 10 assertion failures, 0 errors/skips. Add explicit flag/value/negatable schemas and immutable option occurrences. Preserve occurrence order and valued false; reject duplicate/mixed-schema ambiguity. Snapshot legacy maps and mutable values while retaining Boolean flag compatibility. Do not claim capability discovery or final three-branch integration is complete.
1 parent d02b16d commit 48b1438

3 files changed

Lines changed: 247 additions & 40 deletions

File tree

Lines changed: 101 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,79 @@
11
package io.github.easy4j.opencli.core;
22

33
import io.github.easy4j.opencli.util.OpenCliStrings;
4+
import java.math.BigDecimal;
5+
import java.math.BigInteger;
46
import java.util.ArrayList;
57
import java.util.Collections;
8+
import java.util.HashMap;
9+
import java.util.HashSet;
610
import java.util.LinkedHashMap;
711
import java.util.List;
812
import java.util.Map;
913
import java.util.Objects;
14+
import java.util.Set;
1015
import lombok.AccessLevel;
1116
import lombok.Builder;
1217
import lombok.Getter;
1318
import lombok.Singular;
1419

1520
/**
16-
* Structured adapter request. Positional and valued-option contents are literal;
17-
* command and option identifiers are validated separately.
18-
* The legacy options map represents a single-valued subset: Boolean values
19-
* retain their historical presence-only flag semantics.
21+
* Structured adapter request with immutable literal values and ordered options.
22+
* The legacy options Map retains Boolean presence-only semantics; use
23+
* {@link OpenCliOption} for repeated values, valued false and explicit negation.
2024
*
2125
* @author <a href="https://github.com/loong10k">Loong Wan</a>
2226
* @since 3.0.0
2327
*/
2428
@Getter
2529
@Builder
2630
public final class OpenCliAdapterCommandRequest {
27-
2831
private final String subcommand;
2932

3033
@Getter(AccessLevel.NONE)
3134
@Singular("positional")
3235
private final List<String> positionals;
3336

3437
@Getter(AccessLevel.NONE)
35-
@Builder.Default
36-
private final Map<String, Object> options = Collections.emptyMap();
38+
private final Map<String, Object> options;
39+
40+
@Getter(AccessLevel.NONE)
41+
@Singular("option")
42+
private final List<OpenCliOption> orderedOptions;
43+
44+
/**
45+
* Builder customisation snapshots legacy option values before they can be
46+
* changed through the caller's Map or a mutable value object.
47+
*/
48+
public static class OpenCliAdapterCommandRequestBuilder {
49+
private Map<String, Object> options;
50+
51+
/** @param source legacy single-value options @return this builder */
52+
public OpenCliAdapterCommandRequestBuilder options(Map<String, Object> source) {
53+
options = snapshotOptions(source);
54+
return this;
55+
}
56+
}
3757

3858
/** @return an immutable copy of positional values */
3959
public List<String> getPositionals() {
40-
if (Objects.isNull(positionals)) {
41-
return Collections.emptyList();
42-
}
43-
return Collections.unmodifiableList(new ArrayList<>(positionals));
60+
return positionals == null ? Collections.emptyList()
61+
: Collections.unmodifiableList(new ArrayList<>(positionals));
4462
}
4563

46-
/** @return an immutable copy of named options */
64+
/** @return an immutable snapshot of legacy options */
4765
public Map<String, Object> getOptions() {
48-
if (Objects.isNull(options)) {
49-
return Collections.emptyMap();
50-
}
51-
return Collections.unmodifiableMap(new LinkedHashMap<>(options));
66+
return options == null ? Collections.emptyMap()
67+
: Collections.unmodifiableMap(new LinkedHashMap<>(options));
5268
}
5369

54-
/**
55-
* @return subcommand, then unchanged positional values and named options
56-
*/
70+
/** @return immutable, ordered option occurrences */
71+
public List<OpenCliOption> getOrderedOptions() {
72+
return orderedOptions == null ? Collections.emptyList()
73+
: Collections.unmodifiableList(new ArrayList<>(orderedOptions));
74+
}
75+
76+
/** @return the validated subcommand and complete literal argv */
5777
public List<String> toSubcommandAndArgs() {
5878
Objects.requireNonNull(subcommand, "subcommand");
5979
if (OpenCliStrings.isBlank(subcommand)) {
@@ -64,27 +84,74 @@ public List<String> toSubcommandAndArgs() {
6484
if (positionals != null) {
6585
tokens.addAll(OpenCliArgSupport.snapshotValues(positionals, "positionals"));
6686
}
67-
if (Objects.nonNull(options)) {
87+
Set<String> legacyFlags = new HashSet<>();
88+
if (options != null) {
6889
for (Map.Entry<String, Object> entry : options.entrySet()) {
69-
appendOption(tokens, entry.getKey(), entry.getValue());
90+
String flag = appendLegacyOption(tokens, entry.getKey(), entry.getValue());
91+
if (flag != null && !legacyFlags.add(flag)) {
92+
throw new IllegalArgumentException("Ambiguous duplicate legacy option identifier");
93+
}
94+
}
95+
}
96+
Map<String, OpenCliOptionSchema> seen = new HashMap<>();
97+
Map<String, String> wireNames = new HashMap<>();
98+
if (orderedOptions != null) {
99+
for (int i = 0; i < orderedOptions.size(); i++) {
100+
OpenCliOption occurrence = orderedOptions.get(i);
101+
if (occurrence == null) {
102+
throw new IllegalArgumentException("orderedOptions[" + i + "] must not be null");
103+
}
104+
OpenCliOptionSchema schema = occurrence.getSchema();
105+
String name = schema.getName();
106+
List<String> argv = occurrence.toTokens();
107+
String wireName = argv.get(0);
108+
if (legacyFlags.contains(name) || legacyFlags.contains(wireName)) {
109+
throw new IllegalArgumentException("Legacy and ordered options must not overlap");
110+
}
111+
OpenCliOptionSchema previous = seen.put(name, schema);
112+
if (previous != null && (!schema.isRepeatable() || !previous.equals(schema))) {
113+
throw new IllegalArgumentException("Repeated option is not allowed by one consistent schema");
114+
}
115+
String previousCanonical = wireNames.put(wireName, name);
116+
if (previousCanonical != null && !previousCanonical.equals(name)) {
117+
throw new IllegalArgumentException("Different schemas emit the same option identifier");
118+
}
119+
tokens.addAll(argv);
70120
}
71121
}
72122
return tokens;
73123
}
74124

75-
private static void appendOption(List<String> target, String name, Object value) {
76-
if (OpenCliStrings.isBlank(name) || Objects.isNull(value)) {
77-
return;
78-
}
79-
String flag = name.startsWith("-") ? name.trim() : "--" + name.trim();
80-
if (value instanceof Boolean) {
81-
if (((Boolean) value).booleanValue()) {
82-
target.add(flag);
125+
private static Map<String, Object> snapshotOptions(Map<String, Object> source) {
126+
if (source == null) { return Collections.emptyMap(); }
127+
Map<String, Object> snapshot = new LinkedHashMap<>();
128+
for (Map.Entry<String, Object> entry : new LinkedHashMap<>(source).entrySet()) {
129+
Object value = entry.getValue();
130+
if (value != null) {
131+
Class<?> type = value.getClass();
132+
if (type != String.class && type != Boolean.class && type != Byte.class
133+
&& type != Short.class && type != Integer.class && type != Long.class
134+
&& type != Float.class && type != Double.class && type != BigInteger.class
135+
&& type != BigDecimal.class) {
136+
value = String.valueOf(value);
137+
}
83138
}
84-
return;
139+
snapshot.put(entry.getKey(), value);
85140
}
141+
return Collections.unmodifiableMap(snapshot);
142+
}
143+
144+
private static String appendLegacyOption(List<String> target, String name, Object value) {
145+
if (OpenCliStrings.isBlank(name) || value == null || Boolean.FALSE.equals(value)) {
146+
return null;
147+
}
148+
String normalized = name.trim();
149+
String flag = normalized.startsWith("-") ? normalized : "--" + normalized;
86150
target.add(flag);
87-
target.add(String.valueOf(value));
151+
if (!(value instanceof Boolean)) {
152+
target.add(String.valueOf(value));
153+
}
154+
return flag;
88155
}
89156

90157
/**
@@ -94,16 +161,10 @@ private static void appendOption(List<String> target, String name, Object value)
94161
* @return a structured request
95162
*/
96163
public static OpenCliAdapterCommandRequest of(
97-
String subcommand,
98-
List<String> positionals,
99-
Map<String, Object> options) {
164+
String subcommand, List<String> positionals, Map<String, Object> options) {
100165
OpenCliAdapterCommandRequestBuilder b = builder().subcommand(subcommand);
101-
if (Objects.nonNull(positionals)) {
102-
b.positionals(positionals);
103-
}
104-
if (Objects.nonNull(options) && !options.isEmpty()) {
105-
b.options(new LinkedHashMap<>(options));
106-
}
166+
if (positionals != null) { b.positionals(positionals); }
167+
if (options != null) { b.options(options); }
107168
return b.build();
108169
}
109170
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package io.github.easy4j.opencli.core;
2+
3+
import java.util.Arrays;
4+
import java.util.Collections;
5+
import java.util.List;
6+
import java.util.Objects;
7+
8+
/**
9+
* One immutable, schema-checked option occurrence. Preserve occurrence ordering
10+
* by adding these to a request builder rather than using a Map for repeated flags.
11+
*
12+
* @author <a href="https://github.com/loong10k">Loong Wan</a>
13+
* @since 3.0.0
14+
*/
15+
public final class OpenCliOption {
16+
private final OpenCliOptionSchema schema;
17+
private final String value;
18+
private final boolean negated;
19+
20+
private OpenCliOption(OpenCliOptionSchema schema, String value, boolean negated) {
21+
this.schema = schema;
22+
this.value = value;
23+
this.negated = negated;
24+
}
25+
26+
/**
27+
* @param schema a valued-option definition
28+
* @param value non-null value; false is the literal value "false", not absence
29+
* @return an occurrence capturing the value immediately
30+
*/
31+
public static OpenCliOption value(OpenCliOptionSchema schema, Object value) {
32+
Objects.requireNonNull(schema, "schema");
33+
Objects.requireNonNull(value, "value");
34+
if (schema.getKind() != OpenCliOptionSchema.Kind.VALUE) {
35+
throw new IllegalArgumentException("A flag schema cannot consume a value");
36+
}
37+
return new OpenCliOption(schema, String.valueOf(value), false);
38+
}
39+
40+
/** @param schema a flag definition @return explicit positive presence */
41+
public static OpenCliOption present(OpenCliOptionSchema schema) {
42+
Objects.requireNonNull(schema, "schema");
43+
if (schema.getKind() == OpenCliOptionSchema.Kind.VALUE) {
44+
throw new IllegalArgumentException("A valued option requires a value");
45+
}
46+
return new OpenCliOption(schema, null, false);
47+
}
48+
49+
/** @param schema a negatable flag definition @return an explicit negative flag */
50+
public static OpenCliOption negated(OpenCliOptionSchema schema) {
51+
Objects.requireNonNull(schema, "schema");
52+
if (schema.getKind() != OpenCliOptionSchema.Kind.NEGATABLE_FLAG) {
53+
throw new IllegalArgumentException("This option schema does not declare negation");
54+
}
55+
return new OpenCliOption(schema, null, true);
56+
}
57+
58+
/** @return immutable input definition */
59+
public OpenCliOptionSchema getSchema() { return schema; }
60+
61+
/** @return the captured value, or null for a flag */
62+
public String getValue() { return value; }
63+
64+
/** @return whether this is explicit negative presence */
65+
public boolean isNegated() { return negated; }
66+
67+
/** @return immutable literal tokens; no quoting or trimming is applied */
68+
public List<String> toTokens() {
69+
String flag = negated ? "--no-" + schema.getName().substring(2) : schema.getName();
70+
return value == null ? Collections.singletonList(flag)
71+
: Collections.unmodifiableList(Arrays.asList(flag, value));
72+
}
73+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package io.github.easy4j.opencli.core;
2+
3+
import java.util.Objects;
4+
import java.util.regex.Pattern;
5+
6+
/**
7+
* Explicit option input semantics. Definitions come from a known command contract,
8+
* not guesses about a raw argument vector. This is not an output schema.
9+
*
10+
* @author <a href="https://github.com/loong10k">Loong Wan</a>
11+
* @since 3.0.0
12+
*/
13+
public final class OpenCliOptionSchema {
14+
/** Input arity and negation semantics. */
15+
public enum Kind { FLAG, VALUE, NEGATABLE_FLAG }
16+
17+
private static final Pattern NAME = Pattern.compile("(?:--[A-Za-z0-9][A-Za-z0-9-]*|-[A-Za-z0-9])");
18+
private final String name;
19+
private final Kind kind;
20+
private final boolean repeatable;
21+
22+
private OpenCliOptionSchema(String name, Kind kind, boolean repeatable) {
23+
Objects.requireNonNull(name, "name");
24+
if (!NAME.matcher(name).matches()) {
25+
throw new IllegalArgumentException("Option schema requires a valid flag identifier");
26+
}
27+
if (kind == Kind.NEGATABLE_FLAG && (!name.startsWith("--") || name.startsWith("--no-"))) {
28+
throw new IllegalArgumentException("Negatable schema requires a positive long flag identifier");
29+
}
30+
this.name = name;
31+
this.kind = kind;
32+
this.repeatable = repeatable;
33+
}
34+
35+
/** @param name flag identifier @return a presence-only, nonrepeatable flag */
36+
public static OpenCliOptionSchema flag(String name) {
37+
return new OpenCliOptionSchema(name, Kind.FLAG, false);
38+
}
39+
40+
/**
41+
* @param name option identifier
42+
* @param repeatable whether repeated occurrences are accepted by the command
43+
* @return an option taking one literal value per occurrence
44+
*/
45+
public static OpenCliOptionSchema value(String name, boolean repeatable) {
46+
return new OpenCliOptionSchema(name, Kind.VALUE, repeatable);
47+
}
48+
49+
/** @param name positive long flag identifier @return a flag supporting explicit negation */
50+
public static OpenCliOptionSchema negatableFlag(String name) {
51+
return new OpenCliOptionSchema(name, Kind.NEGATABLE_FLAG, false);
52+
}
53+
54+
/** @return canonical flag identifier */
55+
public String getName() { return name; }
56+
57+
/** @return declared input kind */
58+
public Kind getKind() { return kind; }
59+
60+
/** @return whether the command accepts repeated occurrences */
61+
public boolean isRepeatable() { return repeatable; }
62+
63+
@Override
64+
public boolean equals(Object other) {
65+
if (this == other) { return true; }
66+
if (!(other instanceof OpenCliOptionSchema)) { return false; }
67+
OpenCliOptionSchema that = (OpenCliOptionSchema) other;
68+
return name.equals(that.name) && kind == that.kind && repeatable == that.repeatable;
69+
}
70+
71+
@Override
72+
public int hashCode() { return Objects.hash(name, kind, repeatable); }
73+
}

0 commit comments

Comments
 (0)