001/*
002 * Gridarta MMORPG map editor for Crossfire, Daimonin and similar games.
003 * Copyright (C) 2000-2010 The Gridarta Developers.
004 *
005 * This program is free software; you can redistribute it and/or modify
006 * it under the terms of the GNU General Public License as published by
007 * the Free Software Foundation; either version 2 of the License, or
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU General Public License for more details.
014 *
015 * You should have received a copy of the GNU General Public License along
016 * with this program; if not, write to the Free Software Foundation, Inc.,
017 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
018 */
019
020package net.sf.gridarta.model.validation.checks;
021
022import java.awt.Point;
023import java.io.File;
024import java.io.FileOutputStream;
025import java.io.IOException;
026import java.io.InputStreamReader;
027import java.io.OutputStream;
028import java.io.OutputStreamWriter;
029import java.io.Writer;
030import java.util.concurrent.Semaphore;
031import java.util.concurrent.TimeUnit;
032import java.util.regex.Matcher;
033import java.util.regex.Pattern;
034import net.sf.gridarta.model.archetype.Archetype;
035import net.sf.gridarta.model.gameobject.GameObject;
036import net.sf.gridarta.model.io.MapWriter;
037import net.sf.gridarta.model.maparchobject.MapArchObject;
038import net.sf.gridarta.model.mapmodel.MapModel;
039import net.sf.gridarta.model.mapmodel.MapSquare;
040import net.sf.gridarta.model.validation.AbstractValidator;
041import net.sf.gridarta.model.validation.ErrorCollector;
042import net.sf.gridarta.model.validation.MapValidator;
043import net.sf.gridarta.model.validation.ValidatorPreferences;
044import net.sf.gridarta.utils.CopyReader;
045import net.sf.gridarta.utils.IOUtils;
046import net.sf.gridarta.utils.StringUtils;
047import org.jetbrains.annotations.NotNull;
048import org.jetbrains.annotations.Nullable;
049
050/**
051 * Executes a script to check a map. The script's output is parsed and converted
052 * into validation errors.
053 * @author Andreas Kirschbaum
054 */
055public class MapCheckerScriptChecker<G extends GameObject<G, A, R>, A extends MapArchObject<A>, R extends Archetype<G, A, R>> extends AbstractValidator<G, A, R> implements MapValidator<G, A, R> {
056
057    /**
058     * The maximum execution time of the checker script in milliseconds. If the
059     * script runs longer, it will be terminated.
060     */
061    private static final int MAX_EXEC_TIME = 30000;
062
063    /**
064     * The placeholder in the command's arguments for the map to check.
065     */
066    @NotNull
067    public static final String MAP_PLACEHOLDER = "${MAP}";
068
069    /**
070     * The quoted. placeholder in the command's arguments for the map to check.
071     */
072    @NotNull
073    private static final String QUOTED_MAP_PLACEHOLDER = Pattern.quote(MAP_PLACEHOLDER);
074
075    /**
076     * The {@link Pattern} for matching per map square messages.
077     */
078    @NotNull
079    private static final Pattern PATTERN_MAP_SQUARE_MESSAGE = Pattern.compile("(\\d+)\\s+(\\d+)\\s+(.*)");
080
081    /**
082     * The {@link MapWriter} for saving temporary map files.
083     */
084    @NotNull
085    private final MapWriter<G, A, R> mapWriter;
086
087    /**
088     * The {@link CommandFinder} for the script to execute.
089     */
090    @NotNull
091    private final CommandFinder commandFinder1 = new CommandFinder();
092
093    /**
094     * The {@link CommandFinder} for the script interpreter.
095     */
096    @NotNull
097    private final CommandFinder commandFinder2 = new CommandFinder();
098
099    /**
100     * The command to execute and the arguments to pass.
101     */
102    @NotNull
103    private final String[] args;
104
105    /**
106     * The temp file for saving maps to be checked. Set to <code>null</code>
107     * until created.
108     */
109    @Nullable
110    private File tmpFile;
111
112    /**
113     * Creates a new instance.
114     * @param validatorPreferences the validator preferences to use
115     * @param mapWriter the map writer for saving temporary map files
116     * @param args the command to execute and the arguments to pass
117     */
118    public MapCheckerScriptChecker(@NotNull final ValidatorPreferences validatorPreferences, @NotNull final MapWriter<G, A, R> mapWriter, @NotNull final String[] args) {
119        super(validatorPreferences);
120        this.mapWriter = mapWriter;
121        this.args = args.clone();
122
123        if (this.args.length < 1) {
124            throw new IllegalArgumentException("no script to execute");
125        }
126
127        boolean found = false;
128        for (int i = 1; i < this.args.length; i++) {
129            if (this.args[i].contains(MAP_PLACEHOLDER)) {
130                found = true;
131                break;
132            }
133        }
134        if (!found) {
135            throw new IllegalArgumentException("the script to execute doesn't receive the map to check (" + MAP_PLACEHOLDER + ")");
136        }
137    }
138
139    /**
140     * {@inheritDoc}
141     */
142    @Override
143    public void validateMap(@NotNull final MapModel<G, A, R> mapModel, @NotNull final ErrorCollector<G, A, R> errorCollector) {
144        final String[] command = getCommand(mapModel, errorCollector);
145        if (command == null) {
146            return;
147        }
148        final Process process;
149        try {
150            process = Runtime.getRuntime().exec(command);
151        } catch (final IOException ex) {
152            errorCollector.collect(new MapCheckerScriptMissingError<G, A, R>(mapModel, command[0], ex.getMessage()));
153            return;
154        }
155        @Nullable final String output;
156        try {
157            try {
158                process.getOutputStream().close();
159            } catch (final IOException ex) {
160                errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command[0], ex.getMessage() + " (closing stdin)"));
161                return;
162            }
163
164            output = runProcess(process, errorCollector, mapModel, command[0]);
165        } finally {
166            process.destroy();
167        }
168        if (output != null) {
169            parseOutput(output, errorCollector, mapModel, command[0]);
170        }
171    }
172
173    /**
174     * Parses output of the executed script. Adds validation error to an {@link
175     * ErrorCollector}.
176     * @param output the output to parse
177     * @param errorCollector the error collector to add to
178     * @param mapModel the map model being checked
179     * @param command the command being run
180     */
181    private void parseOutput(@NotNull final CharSequence output, @NotNull final ErrorCollector<G, A, R> errorCollector, @NotNull final MapModel<G, A, R> mapModel, @NotNull final String command) {
182        final String[] lines = StringUtils.PATTERN_END_OF_LINE.split(output);
183        for (final String line : lines) {
184            if (!line.isEmpty()) {
185                final Matcher matcher = PATTERN_MAP_SQUARE_MESSAGE.matcher(line);
186                if (matcher.matches()) {
187                    final int x;
188                    final int y;
189                    try {
190                        x = Integer.parseInt(matcher.group(1));
191                        y = Integer.parseInt(matcher.group(2));
192                    } catch (final NumberFormatException ignored) {
193                        errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "syntax error in '" + line + "'"));
194                        continue;
195                    }
196                    final String message = matcher.group(3);
197                    final MapSquare<G, A, R> mapSquare;
198                    try {
199                        mapSquare = mapModel.getMapSquare(new Point(x, y));
200                    } catch (final IndexOutOfBoundsException ignored) {
201                        errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "invalid map square in '" + line + "'"));
202                        continue;
203                    }
204                    errorCollector.collect(new MapCheckerScriptMapSquareError<G, A, R>(mapSquare, message));
205                } else {
206                    errorCollector.collect(new MapCheckerScriptMapError<G, A, R>(mapModel, line));
207                }
208            }
209        }
210    }
211
212    /**
213     * Waits for a {@link Process} to terminate and returns its output.
214     * @param process the process
215     * @param errorCollector the error collector to add problems to
216     * @param mapModel the map model that is being checked
217     * @param command the command that is being run
218     * @return the command's output
219     */
220    @Nullable
221    private String runProcess(@NotNull final Process process, @NotNull final ErrorCollector<G, A, R> errorCollector, @NotNull final MapModel<G, A, R> mapModel, @NotNull final String command) {
222        @Nullable String output;
223        final InputStreamReader stdoutReader = new InputStreamReader(process.getInputStream());
224        try {
225            final CopyReader stdout = new CopyReader(stdoutReader);
226            final InputStreamReader stderrReader = new InputStreamReader(process.getErrorStream());
227            try {
228                final CopyReader stderr = new CopyReader(stderrReader);
229                stdout.start();
230                try {
231                    stderr.start();
232                    try {
233                        if (!waitForTermination(process, errorCollector, mapModel, command, stdout, stderr)) {
234                            return null;
235                        }
236                    } finally {
237                        stderr.stop();
238                    }
239                } finally {
240                    stdout.stop();
241                }
242            } finally {
243                try {
244                    stderrReader.close();
245                } catch (final IOException ex) {
246                    errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, ex.getMessage() + " (closing stderr)"));
247                }
248            }
249            final String stdoutFailure = stdout.getFailure();
250            if (stdoutFailure != null) {
251                errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, stdoutFailure));
252                return null;
253            }
254            output = stdout.getOutput();
255        } finally {
256            try {
257                stdoutReader.close();
258            } catch (final IOException ex) {
259                errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, ex.getMessage() + " (closing stdout)"));
260            }
261        }
262        return output;
263    }
264
265    /**
266     * Waits for a {@link Process} to terminate.
267     * @param process the process
268     * @param errorCollector the error collector to add problems to
269     * @param mapModel the map model being checked
270     * @param command the command being executed
271     * @param stdout the stdout stream of the process
272     * @param stderr the stderr stream of the process
273     * @return whether the process did exit successfully
274     */
275    private boolean waitForTermination(@NotNull final Process process, @NotNull final ErrorCollector<G, A, R> errorCollector, @NotNull final MapModel<G, A, R> mapModel, @NotNull final String command, @NotNull final CopyReader stdout, @NotNull final CopyReader stderr) {
276        final Semaphore sem = new Semaphore(0);
277        final int[] tmp = { -1, };
278        final Runnable runnable = new Runnable() {
279
280            @Override
281            public void run() {
282                try {
283                    try {
284                        stdout.join();
285                        stderr.join();
286                        tmp[0] = process.waitFor();
287                    } catch (final InterruptedException ignored) {
288                        Thread.currentThread().interrupt();
289                    }
290                } finally {
291                    sem.release();
292                }
293            }
294
295        };
296        final Thread thread = new Thread(runnable);
297        thread.start();
298        try {
299            if (!sem.tryAcquire(MAX_EXEC_TIME, TimeUnit.MILLISECONDS)) {
300                errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "timeout waiting for script to terminate"));
301                return false;
302            }
303        } catch (final InterruptedException ignored) {
304            errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "interrupted waiting for script to terminate"));
305            return false;
306        }
307        thread.interrupt();
308        if (tmp[0] != 0) {
309            errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "command exited with status " + tmp[0]));
310            return false;
311        }
312        final String stderrFailure = stderr.getFailure();
313        if (stderrFailure != null) {
314            errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, stderrFailure));
315            return false;
316        }
317        final String stderrOutput = stderr.getOutput();
318        if (!stderrOutput.isEmpty()) {
319            errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, StringUtils.PATTERN_NEWLINE.matcher(stderrOutput).replaceAll("<br>")));
320            return false;
321        }
322        return true;
323    }
324
325    /**
326     * Returns the command to execute. Returns {@link #args} but replaces {@link
327     * #MAP_PLACEHOLDER} with the map to check.
328     * @param mapModel the map to check
329     * @param errorCollector the error collector for reporting problems
330     * @return the command to execute or <code>null</code> if an error occurred
331     */
332    @Nullable
333    private String[] getCommand(@NotNull final MapModel<G, A, R> mapModel, @NotNull final ErrorCollector<G, A, R> errorCollector) {
334        if (tmpFile == null) {
335            try {
336                tmpFile = File.createTempFile("gridarta", null);
337            } catch (final IOException ex) {
338                errorCollector.collect(new MapCheckerScriptIOError<G, A, R>(mapModel, "create temporary file", ex.getMessage()));
339                return null;
340            }
341            assert tmpFile != null;
342            tmpFile.deleteOnExit();
343        }
344        assert tmpFile != null;
345        final String mapPath = tmpFile.getPath();
346        try {
347            final OutputStream outputStream = new FileOutputStream(tmpFile);
348            try {
349                final Writer writer = new OutputStreamWriter(outputStream, IOUtils.MAP_ENCODING);
350                try {
351                    mapWriter.encodeMapFile(mapModel, writer);
352                } finally {
353                    writer.close();
354                }
355            } finally {
356                outputStream.close();
357            }
358        } catch (final IOException ex) {
359            errorCollector.collect(new MapCheckerScriptIOError<G, A, R>(mapModel, mapPath, ex.getMessage()));
360            return null;
361        }
362
363        final boolean isWindows = System.getProperty("os.name").contains("Windows");
364        final String[] result;
365        int index = 0;
366        try {
367            if (isWindows) {
368                if (args[0].toLowerCase().endsWith(".py")) {
369                    result = new String[args.length + 1];
370                    String command;
371                    try {
372                        command = commandFinder2.getCommand("python.exe");
373                    } catch (final IOException ex) {
374                        command = "C:" + File.separator + "python27" + File.separator + "python.exe";
375                        if (!new File(command).exists()) {
376                            throw ex;
377                        }
378                    }
379                    result[index++] = "\"" + command + "\"";
380                } else {
381                    result = new String[args.length];
382                }
383                result[index++] = "\"" + commandFinder1.getCommand(args[0]) + "\"";
384            } else {
385                result = new String[args.length];
386                result[index++] = commandFinder1.getCommand(args[0]);
387            }
388        } catch (final IOException ex) {
389            errorCollector.collect(new MapCheckerScriptMissingError<G, A, R>(mapModel, args[0], ex.getMessage()));
390            return null;
391        }
392        final String mapPathQuoted = Matcher.quoteReplacement(mapPath);
393        for (int i = 1; i < args.length; i++) {
394            final String tmp = args[i].replaceAll(QUOTED_MAP_PLACEHOLDER, mapPathQuoted);
395            result[index++] = isWindows ? "\"" + tmp + "\"" : tmp;
396        }
397        return result;
398    }
399
400    /**
401     * Searches for commands in the PATH environment variable.
402     * @author Andreas Kirschbaum
403     */
404    private static class CommandFinder {
405
406        /**
407         * The command to execute. Set to <code>null</code> if unknown.
408         * Otherwise points to <code>{@link #args}[0]</code>.
409         */
410        @Nullable
411        private File cachedCommand;
412
413        /**
414         * Returns the command to execute. Returns or updates {@link
415         * #cachedCommand}.
416         * @param commandName the command name to search
417         * @return the command to execute
418         * @throws IOException if the command cannot be found
419         */
420        @NotNull
421        private String getCommand(@NotNull final String commandName) throws IOException {
422            final File existingCommand = cachedCommand;
423            if (existingCommand != null && existingCommand.exists()) {
424                return existingCommand.getPath();
425            }
426
427            final File command = IOUtils.findPathFile(commandName);
428            cachedCommand = command;
429            return command.getPath();
430        }
431
432    }
433
434}