001/*
002 * Copyright 2023 Smartefact
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package smartefact.xdg.desktopentry;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.OutputStream;
022import java.io.Reader;
023import java.io.Writer;
024import java.util.ArrayList;
025import static java.util.Collections.unmodifiableMap;
026import java.util.HashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.regex.Matcher;
030import java.util.regex.Pattern;
031import org.jetbrains.annotations.Contract;
032import org.jetbrains.annotations.NotNull;
033import org.jetbrains.annotations.Nullable;
034import smartefact.Characters;
035import smartefact.Collections;
036import static smartefact.HashCodeBuilder.hashCodeBuilder;
037import smartefact.InputStreams;
038import smartefact.OutputStreams;
039import static smartefact.Preconditions.require;
040import static smartefact.Preconditions.requireNotNull;
041import smartefact.Strings;
042import static smartefact.ToStringBuilder.toStringBuilder;
043
044/**
045 * XDG desktop entry.
046 * <p>
047 * <b>Specification:</b> XDG Desktop Entry 1.5
048 * </p>
049 *
050 * @author Laurent Pireyn
051 * @see <a href="https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html">XDG Desktop Entry Specification</a>
052 */
053public final class DesktopEntry {
054    /**
055     * Line in a desktop entry.
056     * <p>
057     * This is a sealed class.
058     * </p>
059     *
060     * @see Entry
061     * @see Comment
062     * @see BlankLine
063     */
064    public abstract static class Line {
065        Line() {}
066
067        abstract void writeTo(@NotNull Writer writer) throws IOException;
068
069        @Override
070        @Contract(value = "null -> false", pure = true)
071        public abstract boolean equals(@Nullable Object object);
072
073        @Override
074        @Contract(pure = true)
075        public abstract int hashCode();
076
077        @Override
078        @Contract(pure = true)
079        public abstract @NotNull String toString();
080    }
081
082    /**
083     * Entry {@link Line} in a desktop entry.
084     */
085    public static final class Entry extends Line {
086        public static boolean isLegalKeyChar(char c) {
087            return Characters.in(c, 'a', 'z')
088                || Characters.in(c, 'A', 'Z')
089                || Characters.in(c, '0', '9')
090                || c == '-';
091        }
092
093        public static boolean isLegalKey(@NotNull String string) {
094            requireNotNull(string);
095            return !string.isEmpty()
096                && Strings.all(string, Entry::isLegalKeyChar);
097        }
098
099        private final @NotNull String key;
100        private final @NotNull String value;
101
102        public Entry(
103            @NotNull String key,
104            @NotNull String value
105        ) {
106            require(
107                isLegalKey(key),
108                () -> "The string <" + key + "> is an illegal key"
109            );
110            requireNotNull(value);
111            this.key = key;
112            this.value = value;
113        }
114
115        @Contract(pure = true)
116        public @NotNull String getKey() {
117            return key;
118        }
119
120        @Contract(pure = true)
121        public @NotNull String getValue() {
122            return value;
123        }
124
125        @Contract(pure = true)
126        public @Nullable Boolean getValueAsBooleanOrNull() {
127            switch (value) {
128                case "true":
129                    return Boolean.TRUE;
130                case "false":
131                    return Boolean.FALSE;
132                default:
133                    return null;
134            }
135        }
136
137        @Contract(pure = true)
138        public @Nullable Double getValueAsNumericOrNull() {
139            return Strings.toDoubleOrNull(value);
140        }
141
142        @Override
143        void writeTo(@NotNull Writer writer) throws IOException {
144            writer.write(key);
145            writer.write(" = ");
146            for (int i = 0; i < value.length(); ++i) {
147                char c = value.charAt(i);
148                boolean escape = false;
149                switch (c) {
150                    case ' ':
151                        // Escaping the spaces is ugly, but it is necessary to escape leading and trailing spaces
152                        escape = true;
153                        c = 's';
154                        break;
155                    case '\n':
156                        escape = true;
157                        c = 'n';
158                        break;
159                    case '\t':
160                        escape = true;
161                        c = 't';
162                        break;
163                    case '\r':
164                        escape = true;
165                        c = 'r';
166                        break;
167                    case '\\':
168                        escape = true;
169                        c = '\\';
170                        break;
171                }
172                if (escape) {
173                    writer.write('\\');
174                }
175                writer.write(c);
176            }
177            writeEolTo(writer);
178        }
179
180        @Override
181        @Contract(value = "null -> false", pure = true)
182        public boolean equals(@Nullable Object object) {
183            if (this == object) {
184                return true;
185            }
186            if (!(object instanceof Entry)) {
187                return false;
188            }
189            final @NotNull Entry other = (Entry) object;
190            return key.equals(other.key)
191                && value.equals(other.value);
192        }
193
194        @Override
195        @Contract(pure = true)
196        public int hashCode() {
197            return hashCodeBuilder()
198                .property(key)
199                .property(value)
200                .build();
201        }
202
203        @Override
204        @Contract(pure = true)
205        public @NotNull String toString() {
206            return toStringBuilder(this)
207                .property("key", key)
208                .property("value", value)
209                .build();
210        }
211    }
212
213    /**
214     * Comment {@link Line} in a desktop entry.
215     */
216    public static final class Comment extends Line {
217        public static final char HASH = '#';
218
219        private final @NotNull String value;
220
221        public Comment(@NotNull String value) {
222            this.value = requireNotNull(value);
223        }
224
225        @Contract(pure = true)
226        public @NotNull String getValue() {
227            return value;
228        }
229
230        @Override
231        void writeTo(@NotNull Writer writer) throws IOException {
232            writer.write(HASH);
233            writer.write(' ');
234            writer.write(value);
235            writeEolTo(writer);
236        }
237
238        @Override
239        @Contract(value = "null -> false", pure = true)
240        public boolean equals(@Nullable Object object) {
241            if (this == object) {
242                return true;
243            }
244            if (!(object instanceof Comment)) {
245                return false;
246            }
247            final @NotNull Comment other = (Comment) object;
248            return value.equals(other.value);
249        }
250
251        @Override
252        @Contract(pure = true)
253        public int hashCode() {
254            return value.hashCode();
255        }
256
257        @Override
258        @Contract(pure = true)
259        public @NotNull String toString() {
260            return toStringBuilder(this)
261                .property("value", value)
262                .build();
263        }
264    }
265
266    /**
267     * Blank {@link Line} in a desktop entry.
268     */
269    public static final class BlankLine extends Line {
270        public static final BlankLine INSTANCE = new BlankLine();
271
272        private BlankLine() {}
273
274        @Override
275        void writeTo(@NotNull Writer writer) throws IOException {
276            writeEolTo(writer);
277        }
278
279        @Override
280        @Contract(value = "null -> false", pure = true)
281        public boolean equals(@Nullable Object object) {
282            return this == object;
283        }
284
285        @Override
286        @Contract(pure = true)
287        public int hashCode() {
288            return 0;
289        }
290
291        @Override
292        @Contract(pure = true)
293        public @NotNull String toString() {
294            return toStringBuilder(this)
295                .build();
296        }
297    }
298
299    /**
300     * Group in a desktop entry.
301     */
302    public static final class Group {
303        public static final char LSB = '[';
304        public static final char RSB = ']';
305
306        @Contract(pure = true)
307        public static boolean isLegalGroupChar(char c) {
308            return Characters.in(c, (char) 32, (char) 126)
309                && c != LSB && c != RSB;
310        }
311
312        @Contract(pure = true)
313        public static boolean isLegalGroupName(@NotNull String string) {
314            requireNotNull(string);
315            return !string.isEmpty()
316                && Strings.all(string, Group::isLegalGroupChar);
317        }
318
319        private final @NotNull String name;
320        private final @NotNull List<@NotNull Line> lines;
321        private final @NotNull Map<@NotNull String, @NotNull Entry> entriesByKey;
322
323        public Group(
324            @NotNull String name,
325            @NotNull List<@NotNull Line> lines
326        ) {
327            require(
328                isLegalGroupName(name),
329                () -> "The string <" + name + "> is an illegal group name"
330            );
331            requireNotNull(lines);
332            this.name = name;
333            this.lines = Collections.toList(lines);
334            final Map<@NotNull String, @NotNull Entry> entriesByKey = new HashMap<>(lines.size());
335            for (final @NotNull Entry entry : Collections.filterIsInstance(lines, Entry.class)) {
336                final @NotNull String key = entry.getKey();
337                if (entriesByKey.put(key, entry) != null) {
338                    throw new IllegalArgumentException("Duplicate entry <" + key + '>');
339                }
340            }
341            this.entriesByKey = unmodifiableMap(entriesByKey);
342        }
343
344        @Contract(pure = true)
345        public @NotNull String getName() {
346            return name;
347        }
348
349        @Contract(pure = true)
350        public @NotNull List<@NotNull Line> getLines() {
351            return lines;
352        }
353
354        @Contract(pure = true)
355        public @NotNull Map<@NotNull String, @NotNull Entry> getEntriesByKey() {
356            return entriesByKey;
357        }
358
359        void writeTo(@NotNull Writer writer) throws IOException {
360            writer.write(LSB);
361            writer.write(name);
362            writer.write(RSB);
363            writeEolTo(writer);
364            for (final @NotNull Line line : lines) {
365                line.writeTo(writer);
366            }
367        }
368
369        @Override
370        @Contract(value = "null -> false", pure = true)
371        public boolean equals(@Nullable Object object) {
372            if (this == object) {
373                return true;
374            }
375            if (!(object instanceof Group)) {
376                return false;
377            }
378            final @NotNull Group other = (Group) object;
379            return name.equals(other.name)
380                && lines.equals(other.lines);
381        }
382
383        @Override
384        @Contract(pure = true)
385        public int hashCode() {
386            return hashCodeBuilder()
387                .property(name)
388                .property(lines)
389                .build();
390        }
391
392        @Override
393        @Contract(pure = true)
394        public @NotNull String toString() {
395            return toStringBuilder(this)
396                .property("name", name)
397                .property("lines", lines)
398                .build();
399        }
400    }
401
402    private static final class ReadingSession {
403        private static final Pattern PATTERN_COMMENT = Pattern.compile("^#[ \\t]?(.*)$");
404        private static final Pattern PATTERN_GROUP_HEADER = Pattern.compile("^\\[([^\\[\\]]+)]$");
405        private static final Pattern PATTERN_ENTRY = Pattern.compile("^([A-Za-z0-9-]+)[ \\t]*=[ \\t]*(.*)$");
406
407        @Contract(value = "_ -> fail", pure = true)
408        private static boolean parseIllegalLine(@NotNull String line) throws IOException {
409            throw new IOException("The line <" + line + "> is illegal");
410        }
411
412        private @NotNull Reader reader;
413        private final StringBuilder buffer = new StringBuilder(100);
414        private final List<@NotNull Line> currentLines = new ArrayList<>(100);
415        private @Nullable String currentGroupName;
416        private final List<@NotNull Line> initialLines = new ArrayList<>(10);
417        private final List<@NotNull Group> groups = new ArrayList<>(10);
418
419        ReadingSession(@NotNull InputStream input) {
420            reader = InputStreams.toReaderUtf8(input);
421        }
422
423        // This method is not static so it can use buffer
424        private @NotNull String unescape(@NotNull String string) {
425            buffer.setLength(0);
426            boolean escape = false;
427            for (int i = 0; i < string.length(); ++i) {
428                char c = string.charAt(i);
429                if (escape) {
430                    escape = false;
431                    switch (c) {
432                        case 's':
433                            c = ' ';
434                            break;
435                        case 'n':
436                            c = '\n';
437                            break;
438                        case 't':
439                            c = '\t';
440                            break;
441                        case 'r':
442                            c = '\r';
443                            break;
444                        case '\\':
445                            break;
446                        default:
447                            // Ignore illegal escape
448                            // TODO: Should we throw an IllegalArgumentException in this case?
449                    }
450                    buffer.append(c);
451                } else if (c == '\\') {
452                    escape = true;
453                } else {
454                    buffer.append(c);
455                }
456            }
457            return buffer.toString();
458        }
459
460        private void addToCurrentLines(@NotNull Line line) {
461            currentLines.add(line);
462        }
463
464        private void endCurrentLines() throws IOException {
465            if (currentGroupName == null) {
466                initialLines.addAll(currentLines);
467            } else {
468                final Group group;
469                try {
470                    group = new Group(currentGroupName, Collections.toList(currentLines));
471                } catch (IllegalArgumentException e) {
472                    throw new IOException(e.getMessage());
473                }
474                groups.add(group);
475            }
476            currentLines.clear();
477        }
478
479        private @Nullable String readLine() throws IOException {
480            final @Nullable String line;
481            buffer.setLength(0);
482            while (true) {
483                final int c = reader.read();
484                if (c == -1) {
485                    if (buffer.length() == 0) {
486                        return null;
487                    }
488                    line = buffer.toString();
489                    break;
490                }
491                if (c == '\n') {
492                    line = buffer.toString();
493                    break;
494                }
495                buffer.append((char) c);
496            }
497            return Strings.trim(line);
498        }
499
500        @NotNull DesktopEntry readDesktopEntry() throws IOException {
501            while (parseLine());
502            try {
503                return new DesktopEntry(initialLines, groups);
504            } catch (IllegalArgumentException e) {
505                throw new IOException(e.getMessage());
506            }
507        }
508
509        private boolean parseLine() throws IOException {
510            final @Nullable String line = readLine();
511            if (line == null) {
512                endCurrentLines();
513                return false;
514            }
515            return parseBlankLine(line)
516                || parseComment(line)
517                || parseGroupHeader(line)
518                || parseEntry(line)
519                || parseIllegalLine(line);
520        }
521
522        private boolean parseBlankLine(@NotNull String line) {
523            if (!line.isEmpty()) {
524                return false;
525            }
526            addToCurrentLines(BlankLine.INSTANCE);
527            return true;
528        }
529
530        private boolean parseComment(@NotNull String line) {
531            final @NotNull Matcher matcher = PATTERN_COMMENT.matcher(line);
532            if (!matcher.matches()) {
533                return false;
534            }
535            addToCurrentLines(new Comment(matcher.group(1)));
536            return true;
537        }
538
539        private boolean parseGroupHeader(@NotNull String line) throws IOException {
540            final @NotNull Matcher matcher = PATTERN_GROUP_HEADER.matcher(line);
541            if (!matcher.matches()) {
542                return false;
543            }
544            endCurrentLines();
545            currentGroupName = matcher.group(1);
546            return true;
547        }
548
549        private boolean parseEntry(@NotNull String line) {
550            final @NotNull Matcher matcher = PATTERN_ENTRY.matcher(line);
551            if (!matcher.matches()) {
552                return false;
553            }
554            addToCurrentLines(new Entry(matcher.group(1), unescape(matcher.group(2))));
555            return true;
556        }
557    }
558
559    public static @NotNull DesktopEntry readFrom(@NotNull InputStream input) throws IOException {
560        requireNotNull(input);
561        return new ReadingSession(input).readDesktopEntry();
562    }
563
564    private static void writeEolTo(@NotNull Writer writer) throws IOException {
565        writer.write('\n');
566    }
567
568    private final @NotNull List<@NotNull Line> initialLines;
569    private final @NotNull List<@NotNull Group> groups;
570    private final @NotNull Map<@NotNull String, @NotNull Group> groupsByName;
571
572    public DesktopEntry(
573        @NotNull List<@NotNull Line> initialLines,
574        @NotNull List<@NotNull Group> groups
575    ) {
576        requireNotNull(initialLines);
577        requireNotNull(groups);
578        this.initialLines = Collections.toList(initialLines);
579        this.groups = Collections.toList(groups);
580        final Map<@NotNull String, @NotNull Group> groupsByName = new HashMap<>(groups.size());
581        for (final @NotNull Group group : groups) {
582            final @NotNull String name = group.getName();
583            if (groupsByName.put(name, group) != null) {
584                throw new IllegalArgumentException("Duplicate group <" + name + '>');
585            }
586        }
587        this.groupsByName = unmodifiableMap(groupsByName);
588    }
589
590    @Contract(pure = true)
591    public @NotNull List<@NotNull Group> getGroups() {
592        return groups;
593    }
594
595    @Contract(pure = true)
596    public @NotNull Map<@NotNull String, @NotNull Group> getGroupsByName() {
597        return groupsByName;
598    }
599
600    public void writeTo(@NotNull OutputStream output) throws IOException {
601        requireNotNull(output);
602        final @NotNull Writer writer = OutputStreams.toWriterUtf8(output);
603        for (final @NotNull Line line : initialLines) {
604            line.writeTo(writer);
605        }
606        for (final @NotNull Group group : groups) {
607            group.writeTo(writer);
608        }
609        writer.flush();
610    }
611
612    @Override
613    @Contract(value = "null -> false", pure = true)
614    public boolean equals(@Nullable Object object) {
615        if (this == object) {
616            return true;
617        }
618        if (!(object instanceof DesktopEntry)) {
619            return false;
620        }
621        final @NotNull DesktopEntry other = (DesktopEntry) object;
622        return initialLines.equals(other.initialLines)
623            && groups.equals(other.groups);
624    }
625
626    @Override
627    @Contract(pure = true)
628    public int hashCode() {
629        return hashCodeBuilder()
630            .property(initialLines)
631            .property(groups)
632            .build();
633    }
634
635    @Override
636    @Contract(pure = true)
637    public @NotNull String toString() {
638        return toStringBuilder(this)
639            .property("initialLines", initialLines)
640            .property("groups", groups)
641            .build();
642    }
643}