1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.cli.commands;
7   
8   import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
9   import com.fasterxml.jackson.dataformat.yaml.YAMLFactoryBuilder;
10  import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
11  
12  import org.apache.commons.cli.CommandLine;
13  import org.apache.commons.cli.Option;
14  import org.apache.logging.log4j.LogManager;
15  import org.apache.logging.log4j.Logger;
16  
17  import java.io.IOException;
18  import java.io.PrintWriter;
19  import java.io.StringWriter;
20  import java.io.Writer;
21  import java.net.URI;
22  import java.net.URISyntaxException;
23  import java.nio.charset.StandardCharsets;
24  import java.nio.file.Files;
25  import java.nio.file.Path;
26  import java.nio.file.StandardOpenOption;
27  import java.util.Collection;
28  import java.util.Comparator;
29  import java.util.List;
30  import java.util.Map;
31  import java.util.Set;
32  import java.util.TreeMap;
33  import java.util.function.Function;
34  import java.util.stream.Collectors;
35  
36  import dev.metaschema.cli.processor.CallingContext;
37  import dev.metaschema.cli.processor.ExitCode;
38  import dev.metaschema.cli.processor.command.AbstractTerminalCommand;
39  import dev.metaschema.cli.processor.command.CommandExecutionException;
40  import dev.metaschema.cli.processor.command.ExtraArgument;
41  import dev.metaschema.cli.processor.command.ICommandExecutor;
42  import dev.metaschema.core.metapath.DynamicContext;
43  import dev.metaschema.core.metapath.StaticContext;
44  import dev.metaschema.core.metapath.item.node.AllowedValueCollectingNodeItemVisitor;
45  import dev.metaschema.core.metapath.item.node.AllowedValueCollectingNodeItemVisitor.AllowedValuesRecord;
46  import dev.metaschema.core.metapath.item.node.IDefinitionNodeItem;
47  import dev.metaschema.core.metapath.item.node.IModuleNodeItem;
48  import dev.metaschema.core.metapath.item.node.INodeItemFactory;
49  import dev.metaschema.core.model.IModule;
50  import dev.metaschema.core.model.constraint.IAllowedValue;
51  import dev.metaschema.core.model.constraint.IAllowedValuesConstraint;
52  import dev.metaschema.core.model.constraint.IConstraintSet;
53  import dev.metaschema.core.util.ObjectUtils;
54  import dev.metaschema.databind.IBindingContext;
55  import edu.umd.cs.findbugs.annotations.NonNull;
56  
57  /**
58   * A CLI command that lists allowed-values constraints for a Metaschema module,
59   * organized by the target node they apply to.
60   * <p>
61   * The output is produced in YAML format, showing each target location and the
62   * allowed-values constraints that apply to it, including constraint
63   * identifiers, allowed values, and source information.
64   */
65  @SuppressWarnings("PMD.CouplingBetweenObjects")
66  public class ListAllowedValuesCommand
67      extends AbstractTerminalCommand {
68    private static final Logger LOGGER = LogManager.getLogger(ListAllowedValuesCommand.class);
69  
70    @NonNull
71    private static final String COMMAND = "list-allowed-values";
72    @NonNull
73    private static final List<ExtraArgument> EXTRA_ARGUMENTS = ObjectUtils.notNull(List.of(
74        ExtraArgument.newInstance("metaschema-module-file-or-URL", true),
75        ExtraArgument.newInstance("destination-file", false)));
76    @NonNull
77    private static final Option CONSTRAINTS_OPTION = ObjectUtils.notNull(
78        Option.builder("c")
79            .hasArgs()
80            .argName("URL")
81            .desc("additional constraint definitions")
82            .get());
83  
84    @Override
85    public String getName() {
86      return COMMAND;
87    }
88  
89    @Override
90    public String getDescription() {
91      return "List allowed values constraints for the provided Metaschema module";
92    }
93  
94    @SuppressWarnings("null")
95    @Override
96    public Collection<? extends Option> gatherOptions() {
97      return List.of(
98          CONSTRAINTS_OPTION,
99          MetaschemaCommands.OVERWRITE_OPTION);
100   }
101 
102   @Override
103   public List<ExtraArgument> getExtraArguments() {
104     return EXTRA_ARGUMENTS;
105   }
106 
107   @Override
108   public ICommandExecutor newExecutor(CallingContext callingContext, CommandLine cmdLine) {
109     return ICommandExecutor.using(callingContext, cmdLine, this::executeCommand);
110   }
111 
112   /**
113    * Execute the list allowed values command.
114    *
115    * @param callingContext
116    *          information about the calling context
117    * @param cmdLine
118    *          the parsed command line details
119    * @throws CommandExecutionException
120    *           if an error occurred while executing the command
121    */
122   @SuppressWarnings({
123       "PMD.OnlyOneReturn",
124       "PMD.AvoidCatchingGenericException",
125       "PMD.CognitiveComplexity"
126   })
127   protected void executeCommand(
128       @NonNull CallingContext callingContext,
129       @NonNull CommandLine cmdLine) throws CommandExecutionException {
130 
131     List<String> extraArgs = cmdLine.getArgList();
132 
133     Path destination = null;
134     if (extraArgs.size() > 1) {
135       destination = MetaschemaCommands.handleDestination(ObjectUtils.requireNonNull(extraArgs.get(1)), cmdLine);
136     }
137 
138     URI currentWorkingDirectory = ObjectUtils.notNull(getCurrentWorkingDirectory().toUri());
139     Set<IConstraintSet> constraintSets = MetaschemaCommands.loadConstraintSets(
140         cmdLine,
141         CONSTRAINTS_OPTION,
142         currentWorkingDirectory);
143 
144     IBindingContext bindingContext = MetaschemaCommands.newBindingContextWithDynamicCompilation(constraintSets);
145 
146     URI moduleUri;
147     try {
148       moduleUri = resolveAgainstCWD(ObjectUtils.requireNonNull(extraArgs.get(0)));
149     } catch (URISyntaxException ex) {
150       throw new CommandExecutionException(
151           ExitCode.INVALID_ARGUMENTS,
152           String.format("Cannot load module as '%s' is not a valid file or URL. %s",
153               extraArgs.get(0),
154               ex.getLocalizedMessage()),
155           ex);
156     }
157     IModule module = MetaschemaCommands.loadModule(moduleUri, bindingContext);
158 
159     try {
160       if (destination == null) {
161         Writer stringWriter = new StringWriter();
162         try (PrintWriter writer = new PrintWriter(stringWriter)) {
163           generateAllowedValuesList(module, writer);
164         }
165 
166         if (LOGGER.isInfoEnabled()) {
167           LOGGER.info(stringWriter.toString());
168         }
169       } else {
170         try (Writer writer = Files.newBufferedWriter(
171             destination,
172             StandardCharsets.UTF_8,
173             StandardOpenOption.CREATE,
174             StandardOpenOption.WRITE,
175             StandardOpenOption.TRUNCATE_EXISTING)) {
176           try (PrintWriter printWriter = new PrintWriter(writer)) {
177             generateAllowedValuesList(module, printWriter);
178           }
179         }
180       }
181     } catch (IOException ex) {
182       throw new CommandExecutionException(ExitCode.IO_ERROR, ex);
183     } catch (RuntimeException ex) {
184       throw new CommandExecutionException(ExitCode.RUNTIME_ERROR, ex);
185     }
186   }
187 
188   private static void generateAllowedValuesList(
189       @NonNull IModule module,
190       @NonNull PrintWriter writer) throws IOException {
191     AllowedValueCollectingNodeItemVisitor walker = new AllowedValueCollectingNodeItemVisitor();
192 
193     StaticContext staticContext = StaticContext.builder()
194         .defaultModelNamespace(module.getXmlNamespace())
195         .build();
196 
197     IModuleNodeItem moduleNodeItem = INodeItemFactory.instance().newModuleNodeItem(module);
198 
199     DynamicContext dynamicContext = new DynamicContext(staticContext);
200     dynamicContext.disablePredicateEvaluation();
201 
202     walker.visit(moduleNodeItem, dynamicContext);
203 
204     Map<IDefinitionNodeItem<?, ?>,
205         List<AllowedValuesRecord>> allowedValuesByTarget
206             = ObjectUtils.notNull(walker.getAllowedValueLocations().stream()
207                 .flatMap(location -> location.getAllowedValues().stream())
208                 .collect(Collectors.groupingBy(AllowedValuesRecord::getTarget,
209                     () -> new TreeMap<>(Comparator.comparing(IDefinitionNodeItem::getMetapath)),
210                     Collectors.mapping(Function.identity(), Collectors.toUnmodifiableList()))));
211 
212     generateYaml(allowedValuesByTarget, writer);
213   }
214 
215   private static void generateYaml(
216       @NonNull Map<IDefinitionNodeItem<?, ?>, List<AllowedValuesRecord>> allowedValuesByTarget,
217       @NonNull PrintWriter writer) throws IOException {
218 
219     YAMLFactoryBuilder builder = YAMLFactory.builder();
220     YAMLFactory factory = ObjectUtils.notNull(builder
221         .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES)
222         .enable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)
223         .enable(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS)
224         .disable(YAMLGenerator.Feature.SPLIT_LINES)
225         .build());
226 
227     try (YAMLGenerator generator = factory.createGenerator(writer)) {
228 
229       generator.writeStartObject();
230 
231       writeLocations(allowedValuesByTarget, generator);
232 
233       generator.writeEndObject();
234     }
235   }
236 
237   private static void writeLocations(
238       @NonNull Map<IDefinitionNodeItem<?, ?>, List<AllowedValuesRecord>> allowedValuesByTarget,
239       @NonNull YAMLGenerator generator) throws IOException {
240     generator.writeFieldName("locations");
241     generator.writeStartObject();
242 
243     for (Map.Entry<IDefinitionNodeItem<?, ?>, List<AllowedValuesRecord>> entry : allowedValuesByTarget.entrySet()) {
244       assert entry != null;
245       writeLocation(entry, generator);
246     }
247     generator.writeEndObject();
248   }
249 
250   private static void writeLocation(
251       @NonNull Map.Entry<IDefinitionNodeItem<?, ?>, List<AllowedValuesRecord>> entry,
252       @NonNull YAMLGenerator generator) throws IOException {
253 
254     IDefinitionNodeItem<?, ?> target = ObjectUtils.notNull(entry.getKey());
255 
256     generator.writeFieldName(metapath(target));
257 
258     generator.writeStartObject();
259 
260     writeLocationConstraints(entry, generator);
261 
262     generator.writeEndObject();
263   }
264 
265   private static void writeLocationConstraints(
266       @NonNull Map.Entry<IDefinitionNodeItem<?, ?>, List<AllowedValuesRecord>> entry,
267       @NonNull YAMLGenerator generator) throws IOException {
268     IDefinitionNodeItem<?, ?> target = ObjectUtils.notNull(entry.getKey());
269 
270     List<AllowedValuesRecord> allowedValues = entry.getValue();
271     if (allowedValues != null) {
272       generator.writeFieldName("constraints");
273 
274       generator.writeStartArray();
275 
276       for (AllowedValuesRecord record : allowedValues) {
277         assert target.equals(record.getTarget());
278 
279         writeAllowedValue(record, generator);
280       }
281 
282       generator.writeEndArray();
283     }
284   }
285 
286   private static void writeAllowedValue(@NonNull AllowedValuesRecord record, @NonNull YAMLGenerator generator)
287       throws IOException {
288 
289     generator.writeStartObject();
290 
291     generator.writeStringField("type", "allowed-values");
292 
293     IAllowedValuesConstraint constraint = record.getAllowedValues();
294     if (constraint.getId() != null) {
295       generator.writeStringField("identifier", constraint.getId());
296     }
297     generator.writeStringField("location", metapath(record.getLocation()));
298     generator.writeStringField("target", constraint.getTarget().getPath());
299 
300     List<String> values = constraint.getAllowedValues().values().stream()
301         .map(IAllowedValue::getValue)
302         .collect(Collectors.toList());
303     generator.writeFieldName("values");
304     if (values == null) {
305       generator.writeNull();
306     } else {
307       generator.writeStartArray();
308       for (String value : values) {
309         generator.writeString(value);
310       }
311       generator.writeEndArray();
312     }
313 
314     generator.writeBooleanField("allow-other", constraint.isAllowedOther());
315 
316     URI source = constraint.getSource().getSource();
317     generator.writeStringField("source", source == null ? "builtin" : source.toString());
318 
319     generator.writeEndObject();
320   }
321 
322   private static String metapath(@NonNull IDefinitionNodeItem<?, ?> item) {
323     return metapath(item.getMetapath());
324   }
325 
326   private static String metapath(@NonNull String path) {
327     // remove position 1 predicates
328     return path.replace("[1]", "");
329   }
330 }