001/*
002 * Copyright (C) 2022 - 2023, the original author or authors.
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 */
016package io.github.ascopes.jct.filemanagers;
017
018import static java.util.Objects.requireNonNull;
019
020import io.github.ascopes.jct.utils.FileUtils;
021import io.github.ascopes.jct.utils.ToStringBuilder;
022import java.io.BufferedInputStream;
023import java.io.BufferedOutputStream;
024import java.io.BufferedReader;
025import java.io.BufferedWriter;
026import java.io.IOException;
027import java.io.InputStream;
028import java.io.InputStreamReader;
029import java.io.OutputStream;
030import java.io.OutputStreamWriter;
031import java.net.URI;
032import java.nio.ByteBuffer;
033import java.nio.charset.Charset;
034import java.nio.charset.CharsetDecoder;
035import java.nio.charset.CodingErrorAction;
036import java.nio.charset.StandardCharsets;
037import java.nio.file.FileSystem;
038import java.nio.file.Files;
039import java.nio.file.NoSuchFileException;
040import java.nio.file.Path;
041import javax.lang.model.element.Modifier;
042import javax.lang.model.element.NestingKind;
043import javax.tools.FileObject;
044import javax.tools.JavaFileManager.Location;
045import javax.tools.JavaFileObject;
046import org.apiguardian.api.API;
047import org.apiguardian.api.API.Status;
048import org.jspecify.annotations.Nullable;
049import org.slf4j.Logger;
050import org.slf4j.LoggerFactory;
051
052/**
053 * A very simple {@link JavaFileObject} that points to a path on some {@link FileSystem} somewhere.
054 *
055 * <p>This object will always use UTF-8 encoding when obtaining readers or writers.
056 *
057 * <p>No access level or nesting kind information is provided by this implementation.
058 *
059 * @author Ashley Scopes
060 * @since 0.0.1
061 */
062@API(since = "0.0.1", status = Status.STABLE)
063public final class PathFileObject implements JavaFileObject {
064
065  private static final Logger LOGGER = LoggerFactory.getLogger(PathFileObject.class);
066  private static final Charset CHARSET = StandardCharsets.UTF_8;
067  private static final long NOT_MODIFIED = 0L;
068
069  private final Location location;
070  private final Path rootPath;
071  private final Path relativePath;
072  private final Path fullPath;
073  private final String name;
074  private final URI uri;
075  private final Kind kind;
076
077  /**
078   * Initialize this file object.
079   *
080   * @param location     the location that the file object is located within.
081   * @param rootPath     the root directory that the path is a package within.
082   * @param relativePath the path to point to, relative to the root.
083   */
084  public PathFileObject(Location location, Path rootPath, Path relativePath) {
085    requireNonNull(location, "location");
086    requireNonNull(rootPath, "rootPath");
087    requireNonNull(relativePath, "relativePath");
088
089    if (!rootPath.isAbsolute()) {
090      throw new IllegalArgumentException("Expected rootPath to be absolute, but got " + rootPath);
091    }
092
093    this.location = location;
094    this.rootPath = rootPath;
095
096    this.relativePath = relativePath.isAbsolute()
097        ? rootPath.relativize(relativePath)
098        : relativePath;
099
100    fullPath = rootPath.resolve(relativePath);
101    name = this.relativePath.toString();
102    uri = fullPath.toUri();
103    kind = FileUtils.pathToKind(relativePath);
104  }
105
106  /**
107   * Attempt to delete the file.
108   *
109   * @return {@code true} if the file was deleted, or {@code false} if it was not deleted.
110   */
111  @Override
112  public boolean delete() {
113    try {
114      return Files.deleteIfExists(fullPath);
115    } catch (IOException ex) {
116      LOGGER.debug("Ignoring error deleting {}", uri, ex);
117      return false;
118    }
119  }
120
121  /**
122   * Determine if this object equals another.
123   *
124   * @param other the other object to compare with.
125   * @return {@code true} if the other object is also a {@link FileObject} and has the same
126   *     {@link #toUri() URI} as this object, or {@code false} otherwise.
127   */
128  @Override
129  public boolean equals(@Nullable Object other) {
130    // Roughly the same as what Javac does.
131    return other instanceof FileObject && uri.equals(((FileObject) other).toUri());
132  }
133
134  /**
135   * Get the class access level, where appropriate.
136   *
137   * <p>In this implementation, this class will always return {@code null}, since this
138   * information is not readily available without preloading the file in question and parsing it
139   * first.
140   *
141   * <p>At the time of writing, the OpenJDK implementations of the JavaFileObject class
142   * do not provide an implementation for this method either.
143   *
144   * @return {@code null}, always. This implementation does not provide this functionality.
145   */
146  @Override
147  public Modifier getAccessLevel() {
148    return unknown();
149  }
150
151  /**
152   * Read the character content of the file into memory and decode it using the default character
153   * set.
154   *
155   * @param ignoreEncodingErrors ignore encoding errors if {@code true}, or throw them otherwise.
156   * @return the character content, encoded as UTF-8.
157   * @throws IOException if an IO error occurs.
158   */
159  @Override
160  public String getCharContent(boolean ignoreEncodingErrors) throws IOException {
161    try (var input = openInputStream()) {
162      return decoder(ignoreEncodingErrors)
163          .decode(ByteBuffer.wrap(input.readAllBytes()))
164          .toString();
165    }
166  }
167
168  /**
169   * Get the full path of this file object.
170   *
171   * @return the full path.
172   */
173  public Path getFullPath() {
174    return fullPath;
175  }
176
177  /**
178   * Get the kind of the file.
179   *
180   * @return the inferred file kind.
181   */
182  @Override
183  public Kind getKind() {
184    return kind;
185  }
186
187  /**
188   * Determine when the file was last modified.
189   *
190   * @return the timestamp in milliseconds since UNIX epoch that the file was last modified at, or
191   *     {@code 0L} if unmodified.
192   */
193  @Override
194  public long getLastModified() {
195    try {
196      return Files.getLastModifiedTime(fullPath).toMillis();
197    } catch (IOException ex) {
198      LOGGER.debug("Ignoring error reading last modified time for {}", uri, ex);
199      return NOT_MODIFIED;
200    }
201  }
202
203  /**
204   * Get the location of this path file object.
205   *
206   * @return the location.
207   */
208  public Location getLocation() {
209    return location;
210  }
211
212  /**
213   * Get the file name as a string.
214   *
215   * @return the name of the file.
216   */
217  @Override
218  public String getName() {
219    return name;
220  }
221
222  /**
223   * Determine the class nesting kind, where appropriate.
224   *
225   * <p>In this implementation, this class will always return {@code null}, since this
226   * information is not readily available without preloading the file in question and parsing it
227   * first.
228   *
229   * <p>At the time of writing, the OpenJDK implementations of the JavaFileObject class
230   * do not provide an implementation for this method either.
231   *
232   * @return {@code null} in all cases, this operation is not implemented.
233   */
234  @Override
235  public NestingKind getNestingKind() {
236    return unknown();
237  }
238
239  /**
240   * Get the relative path of this file object.
241   *
242   * @return the path of this file object.
243   */
244  public Path getRelativePath() {
245    return relativePath;
246  }
247
248  /**
249   * Get the root path that the package containing this file is nested within.
250   *
251   * @return the root path.
252   */
253  public Path getRootPath() {
254    return rootPath;
255  }
256
257  /**
258   * Determine the hash code for this object.
259   *
260   * @return the hash code for the object.
261   */
262  @Override
263  public int hashCode() {
264    // Corresponds to what the .equals override checks for.
265    return uri.hashCode();
266  }
267
268  /**
269   * Determine if a given simple name and file kind are compatible with this file object.
270   *
271   * <p>This will perform a case-sensitive check, regardless of the platform that it runs on.
272   *
273   * @return {@code true} if the simple name and kind are compatible with the current file object
274   *     name, or {@code false} if not.
275   */
276  @Override
277  public boolean isNameCompatible(String simpleName, Kind kind) {
278    // Note that this behaves case-sensitively, even on Windows.
279    var fileName = simpleName + kind.extension;
280
281    return relativePath.getFileName().toString().equals(fileName);
282  }
283
284  /**
285   * Open an input stream into this file.
286   *
287   * <p>This input stream must be closed once finished with, otherwise
288   * resources will be leaked.
289   *
290   * <p>The returned implementation will always be buffered.
291   *
292   * @return a buffered input stream.
293   * @throws NoSuchFileException if the file does not exist.
294   * @throws IOException         if an IO error occurs.
295   */
296  @Override
297  public BufferedInputStream openInputStream() throws IOException {
298    return new BufferedInputStream(openUnbufferedInputStream());
299  }
300
301  /**
302   * Open an output stream to this file.
303   *
304   * <p>This will create the file first if it does not already exist. If it does
305   * exist, then this will overwrite the file and truncate it.
306   *
307   * <p>This output stream must be closed once finished with, otherwise
308   * resources will be leaked.
309   *
310   * <p>The returned implementation will always be buffered.
311   *
312   * @return a buffered output stream.
313   * @throws IOException if an IO error occurs.
314   */
315  @Override
316  public BufferedOutputStream openOutputStream() throws IOException {
317    return new BufferedOutputStream(openUnbufferedOutputStream());
318  }
319
320  /**
321   * Open a reader to this file using the default charset (UTF-8).
322   *
323   * <p>This reader must be closed once finished with, otherwise
324   * resources will be leaked.
325   *
326   * <p>The returned implementation will always be buffered.
327   *
328   * @param ignoreEncodingErrors {@code true} to suppress encoding errors, or {@code false} to throw
329   *                             them to the caller.
330   * @return a buffered reader.
331   * @throws NoSuchFileException if the file does not exist.
332   * @throws IOException         if an IO error occurs.
333   */
334  @Override
335  public BufferedReader openReader(boolean ignoreEncodingErrors) throws IOException {
336    var inputStream = openUnbufferedInputStream();
337    var decoder = decoder(ignoreEncodingErrors);
338    var reader = new InputStreamReader(inputStream, decoder);
339    return new BufferedReader(reader);
340  }
341
342  /**
343   * Open a writer to this file using the default charset (UTF-8).
344   *
345   * <p>This will create the file first if it does not already exist. If it does exist,
346   * this will first overwrite the file and truncate it.
347   *
348   * <p>This input stream must be closed once finished with, otherwise
349   * resources will be leaked.
350   *
351   * <p>The returned implementation will always be buffered.
352   *
353   * @return a buffered writer.
354   * @throws IOException if an IO error occurs.
355   */
356  @Override
357  public BufferedWriter openWriter() throws IOException {
358    var outputStream = openUnbufferedOutputStream();
359    var encoder = CHARSET
360        .newEncoder()
361        .onUnmappableCharacter(CodingErrorAction.REPORT)
362        .onMalformedInput(CodingErrorAction.REPORT);
363    var writer = new OutputStreamWriter(outputStream, encoder);
364    return new BufferedWriter(writer);
365  }
366
367  /**
368   * Determine the URI for this file.
369   *
370   * @return the URI for this file object.
371   */
372  @Override
373  public URI toUri() {
374    return uri;
375  }
376
377  /**
378   * Get a string representation of this object.
379   *
380   * @return a string representation of this object.
381   */
382  @Override
383  public String toString() {
384    return new ToStringBuilder(this)
385        .attribute("uri", uri)
386        .toString();
387  }
388
389  private InputStream openUnbufferedInputStream() throws IOException {
390    return Files.newInputStream(fullPath);
391  }
392
393  private OutputStream openUnbufferedOutputStream() throws IOException {
394    // Ensure parent directories exist first.
395    Files.createDirectories(fullPath.getParent());
396    return Files.newOutputStream(fullPath);
397  }
398
399  private CharsetDecoder decoder(boolean ignoreEncodingErrors) {
400    var action = ignoreEncodingErrors
401        ? CodingErrorAction.IGNORE
402        : CodingErrorAction.REPORT;
403
404    return CHARSET
405        .newDecoder()
406        .onUnmappableCharacter(action)
407        .onMalformedInput(action);
408  }
409
410  private <T> T unknown() {
411    return null;
412  }
413}