View Javadoc
1   // Generated by delombok at Mon Apr 14 16:48:01 UTC 2025
2   package de.larssh.jes.parser;
3   
4   import static de.larssh.utils.text.Strings.NEW_LINE;
5   import static java.util.stream.Collectors.toList;
6   import java.io.BufferedReader;
7   import java.io.IOException;
8   import java.util.ArrayList;
9   import java.util.Arrays;
10  import java.util.List;
11  import java.util.Optional;
12  import java.util.OptionalInt;
13  import java.util.regex.Matcher;
14  import java.util.regex.Pattern;
15  import org.apache.commons.net.ftp.FTPFileEntryParser;
16  import de.larssh.jes.Job;
17  import de.larssh.jes.JobFlag;
18  import de.larssh.jes.JobOutput;
19  import de.larssh.jes.JobStatus;
20  import de.larssh.utils.Nullables;
21  import de.larssh.utils.Optionals;
22  import de.larssh.utils.text.Lines;
23  import de.larssh.utils.text.Patterns;
24  import de.larssh.utils.text.Strings;
25  import edu.umd.cs.findbugs.annotations.NonNull;
26  import edu.umd.cs.findbugs.annotations.Nullable;
27  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
28  
29  /**
30   * Implementation of {@link FTPFileEntryParser} for IBM z/OS JES spools,
31   * converting that information into a {@link JesFtpFile} instance.
32   */
33  public class JesFtpFileEntryParser implements FTPFileEntryParser {
34  	/**
35  	 * Pattern to match the job details header response line
36  	 */
37  	private static final Pattern PATTERN_TITLE = Pattern.compile("^JOBNAME +JOBID +OWNER +STATUS +CLASS *$", Pattern.CASE_INSENSITIVE);
38  	/**
39  	 * Pattern to match job details lines inside response
40  	 *
41  	 * <p>
42  	 * List of named groups:
43  	 * <ul>
44  	 * <li>name
45  	 * <li>id
46  	 * <li>owner
47  	 * <li>status
48  	 * <li>class
49  	 * <li>rest
50  	 * </ul>
51  	 */
52  	private static final Pattern PATTERN_JOB = Pattern.compile("^(?<name>[^ ]+) +(?<id>[^ ]+) +(?<owner>[^ ]+) +(?<status>(INPUT|ACTIVE|OUTPUT)) +(?<class>[^ ]+)( +(?<rest>.*))?$", Pattern.CASE_INSENSITIVE);
53  	/**
54  	 * Pattern to find the abend code inside a response line
55  	 *
56  	 * <p>
57  	 * List of named groups:
58  	 * <ul>
59  	 * <li>abend
60  	 * </ul>
61  	 */
62  	private static final Pattern PATTERN_JOB_ABEND = Pattern.compile("ABEND=(?<abend>\\S+)", Pattern.CASE_INSENSITIVE);
63  	/**
64  	 * Pattern to find the result code inside a response line
65  	 *
66  	 * <p>
67  	 * List of named groups:
68  	 * <ul>
69  	 * <li>returnCode
70  	 * </ul>
71  	 */
72  	private static final Pattern PATTERN_JOB_RETURN_CODE = Pattern.compile("RC=(?<returnCode>\\d+)", Pattern.CASE_INSENSITIVE);
73  	/**
74  	 * Pattern to match the separator response line OR an output warning
75  	 */
76  	private static final Pattern PATTERN_GARBAGE = Pattern.compile("^(-+|STEP, PROC, CPUT, and ELAPT unknown) *$", Pattern.CASE_INSENSITIVE);
77  	/**
78  	 * Pattern to match the job output header response line
79  	 */
80  	private static final Pattern PATTERN_SUB_TITLE = Pattern.compile("^ {9}ID  STEPNAME PROCSTEP C DDNAME   (BYTE|REC)-COUNT( COMMENT)? *$", Pattern.CASE_INSENSITIVE);
81  	/**
82  	 * Pattern to match job output lines inside response
83  	 *
84  	 * <p>
85  	 * List of named groups:
86  	 * <ul>
87  	 * <li>index
88  	 * <li>step
89  	 * <li>procedureStep
90  	 * <li>class
91  	 * <li>name
92  	 * <li>length
93  	 * </ul>
94  	 */
95  	private static final Pattern PATTERN_JOB_OUTPUT = Pattern.compile("^ {9}(?<index>\\d{3}) (?<step>.{8}) (?<procedureStep>.{8}) (?<class>.) (?<name>.{8}) +(?<length>\\d+) *$");
96  	/**
97  	 * Pattern to match the response line containing the number of spool files
98  	 */
99  	private static final Pattern PATTERN_SPOOL_FILES = Pattern.compile("^\\d+ spool files *$", Pattern.CASE_INSENSITIVE);
100 
101 	/**
102 	 * Builds a {@link Job} object based on a job details response {@code line}.
103 	 *
104 	 * @param line job details response line
105 	 * @return {@link Job} object
106 	 */
107 	@SuppressWarnings("checkstyle:MultipleStringLiterals")
108 	private Job createJob(final String line) {
109 		final Matcher matcher = Patterns.matches(PATTERN_JOB, line).orElseThrow(() -> new JesFtpFileEntryParserException("Expected [%s] as job details line, got [%s].", PATTERN_JOB.pattern(), line));
110 		final String jobId = matcher.group("id");
111 		final String name = matcher.group("name");
112 		final JobStatus status = JobStatus.valueOf(matcher.group("status"));
113 		final String owner = matcher.group("owner");
114 		final Optional<String> jesClass = Optionals.ofNonBlank(matcher.group("class"));
115 		final Optional<String> rest = Optionals.ofNonBlank(matcher.group("rest"));
116 		final OptionalInt resultCode = Optionals.mapToInt(rest.flatMap(r -> Patterns.find(PATTERN_JOB_RETURN_CODE, r)).map(m -> m.group("returnCode")), Integer::parseInt);
117 		final Optional<String> abendCode = rest.flatMap(r -> Patterns.find(PATTERN_JOB_ABEND, r)).map(m -> m.group("abend"));
118 		final List<JobFlag> flags = Arrays.stream(JobFlag.values()).filter(flag -> rest.map(r -> Patterns.find(flag.getRestPattern(), r).isPresent()).orElse(Boolean.FALSE)).collect(toList());
119 		return new Job(jobId, name, status, owner, jesClass, resultCode, abendCode, flags.toArray(new JobFlag[0]));
120 	}
121 
122 	/**
123 	 * Parses {@code listEntry} and builds a {@link Job} object based on the given
124 	 * content.
125 	 *
126 	 * <p>
127 	 * Job details and job outputs were joined together using
128 	 * {@link #preParse(List)} before.
129 	 *
130 	 * @param listEntry one logical line from the file listing
131 	 * @return {@link Job} object
132 	 * @throws JesFtpFileEntryParserException on unexpected {@code listEntry}
133 	 */
134 	@SuppressWarnings("PMD.CyclomaticComplexity")
135 	private Job createJobAndOutputs(final String listEntry) {
136 		final List<String> lines = new ArrayList<>(Lines.lines(listEntry));
137 		if (lines.isEmpty()) {
138 			throw new JesFtpFileEntryParserException("Expected [%s] as job details line, got no line.", PATTERN_JOB.pattern());
139 		}
140 		// First line (job)
141 		final Job job = createJob(lines.remove(0));
142 		// Last line (number of spool files, optional)
143 		if (!lines.isEmpty()) {
144 			final String spoolFiles = lines.get(lines.size() - 1);
145 			if (Strings.matches(spoolFiles, PATTERN_SPOOL_FILES)) {
146 				lines.remove(lines.size() - 1);
147 			}
148 		}
149 		// Second line(s) (optional garbage, e.g. a separator line or an output warning)
150 		while (!lines.isEmpty() && Strings.matches(lines.get(0), PATTERN_GARBAGE)) {
151 			lines.remove(0);
152 		}
153 		// Third line (sub title)
154 		if (lines.isEmpty()) {
155 			return job;
156 		}
157 		final String subTitle = lines.remove(0);
158 		if (!Strings.matches(subTitle, PATTERN_SUB_TITLE)) {
159 			throw new JesFtpFileEntryParserException("Expected [%s] as sub title line, got [%s].", PATTERN_SUB_TITLE.pattern(), subTitle);
160 		}
161 		// Further lines (job outputs)
162 		for (final String line : lines) {
163 			createJobOutput(job, line);
164 		}
165 		return job;
166 	}
167 
168 	/**
169 	 * Builds a {@link JobOutput} object based on a job output response
170 	 * {@code line}.
171 	 *
172 	 * @param job  related job
173 	 * @param line job details response line
174 	 * @return {@link JobOutput} object
175 	 */
176 	@SuppressWarnings("checkstyle:MultipleStringLiterals")
177 	private JobOutput createJobOutput(final Job job, final String line) {
178 		final Matcher matcher = Patterns.matches(PATTERN_JOB_OUTPUT, line).orElseThrow(() -> new JesFtpFileEntryParserException("Expected [%s] as job output details line, got [%s].", PATTERN_JOB_OUTPUT.pattern(), line));
179 		final int index = Integer.parseInt(matcher.group("index"));
180 		final String name = matcher.group("name");
181 		final int length = Integer.parseInt(matcher.group("length"));
182 		final Optional<String> step = Optionals.ofNonBlank(matcher.group("step"));
183 		final Optional<String> procedureStep = Optionals.ofNonBlank(matcher.group("procedureStep"));
184 		final Optional<String> jesClass = Optionals.ofNonBlank(matcher.group("class"));
185 		return job.createOutput(index, name, length, step, procedureStep, jesClass);
186 	}
187 
188 	/**
189 	 * {@inheritDoc}
190 	 */
191 	@Nullable
192 	@Override
193 	public JesFtpFile parseFTPEntry(@Nullable final String listEntryNullable) {
194 		final String listEntry = Nullables.orElseThrow(listEntryNullable);
195 		return new JesFtpFile(createJobAndOutputs(listEntry), listEntry);
196 	}
197 
198 	/**
199 	 * {@inheritDoc}
200 	 */
201 	@Nullable
202 	@Override
203 	@SuppressWarnings({"checkstyle:SuppressWarnings", "resource"})
204 	public String readNextEntry(@Nullable final BufferedReader reader) throws IOException {
205 		return Nullables.orElseThrow(reader).readLine();
206 	}
207 
208 	/**
209 	 * {@inheritDoc}
210 	 */
211 	@NonNull
212 	@Override
213 	@SuppressWarnings("PMD.CyclomaticComplexity")
214 	@SuppressFBWarnings(value = "CFS_CONFUSING_FUNCTION_SEMANTICS", justification = "returning input variable as required by interface contract")
215 	public List<String> preParse(@Nullable final List<String> originalNullable) {
216 		final List<String> original = Nullables.orElseThrow(originalNullable);
217 		// Empty list
218 		if (original.isEmpty()) {
219 			throw new JesFtpFileEntryParserException("Parsing JES job details failed. No line found.");
220 		}
221 		// First line
222 		if (!Strings.matches(original.get(0), PATTERN_TITLE)) {
223 			throw new JesFtpFileEntryParserException("Parsing JES job details failed. Unexpected first line: [%s].", original.get(0));
224 		}
225 		// Iterate over original from 1 to size
226 		// 1. ignore title line (starting at 1)
227 		// 2. concatenate lines of the same job to one list entry
228 		// 3. handle last line (stopping at size)
229 		final List<String> lines = new ArrayList<>();
230 		final List<String> linesOfCurrentJob = new ArrayList<>();
231 		final int size = original.size();
232 		for (int index = 1; index <= size; index += 1) {
233 			final boolean isLast = index >= size;
234 			if ((isLast || Patterns.matches(PATTERN_JOB, original.get(index)).isPresent()) && !linesOfCurrentJob.isEmpty()) {
235 				lines.add(String.join(NEW_LINE, linesOfCurrentJob));
236 				linesOfCurrentJob.clear();
237 			}
238 			if (!isLast) {
239 				linesOfCurrentJob.add(original.get(index));
240 			}
241 		}
242 		// The interface tells us to return the input variable.
243 		original.clear();
244 		original.addAll(lines);
245 		return original;
246 	}
247 
248 	@java.lang.SuppressWarnings("all")
249 	@edu.umd.cs.findbugs.annotations.SuppressFBWarnings(justification = "generated code")
250 	@lombok.Generated
251 	public JesFtpFileEntryParser() {
252 	}
253 }