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}