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    // TODO(ascopes): should we allow absolute paths here? Not sure that it makes a lot of sense
097    // here. 
098    this.relativePath = relativePath.isAbsolute()
099        ? rootPath.relativize(relativePath)
100        : relativePath;
101
102    fullPath = rootPath.resolve(relativePath);
103    name = this.relativePath.toString();
104    uri = fullPath.toUri();
105    kind = FileUtils.pathToKind(relativePath);
106  }
107
108  /**
109   * Attempt to delete the file.
110   *
111   * @return {@code true} if the file was deleted, or {@code false} if it was not deleted.
112   */
113  @Override
114  public boolean delete() {
115    try {
116      return Files.deleteIfExists(fullPath);
117    } catch (IOException ex) {
118      LOGGER.debug("Ignoring error deleting {}", uri, ex);
119      return false;
120    }
121  }
122
123  /**
124   * Determine if this object equals another.
125   *
126   * @param other the other object to compare with.
127   * @return {@code true} if the other object is also a {@link FileObject} and has the same
128   *     {@link #toUri() URI} as this object, or {@code false} otherwise.
129   */
130  @Override
131  public boolean equals(@Nullable Object other) {
132    // Roughly the same as what Javac does.
133    return other instanceof FileObject && uri.equals(((FileObject) other).toUri());
134  }
135
136  /**
137   * Get the class access level, where appropriate.
138   *
139   * <p>In this implementation, this class will always return {@code null}, since this
140   * information is not readily available without preloading the file in question and parsing it
141   * first.
142   *
143   * <p>At the time of writing, the OpenJDK implementations of the JavaFileObject class
144   * do not provide an implementation for this method either.
145   *
146   * @return {@code null}, always. This implementation does not provide this functionality.
147   */
148  @Override
149  public Modifier getAccessLevel() {
150    return unknown();
151  }
152
153  /**
154   * Read the character content of the file into memory and decode it using the default character
155   * set.
156   *
157   * @param ignoreEncodingErrors ignore encoding errors if {@code true}, or throw them otherwise.
158   * @return the character content, encoded as UTF-8.
159   * @throws IOException if an IO error occurs.
160   */
161  @Override
162  public String getCharContent(boolean ignoreEncodingErrors) throws IOException {
163    try (var input = openInputStream()) {
164      return decoder(ignoreEncodingErrors)
165          .decode(ByteBuffer.wrap(input.readAllBytes()))
166          .toString();
167    }
168  }
169
170  /**
171   * Get the full path of this file object.
172   *
173   * @return the full path.
174   */
175  public Path getFullPath() {
176    return fullPath;
177  }
178
179  /**
180   * Get the kind of the file.
181   *
182   * @return the inferred file kind.
183   */
184  @Override
185  public Kind getKind() {
186    return kind;
187  }
188
189  /**
190   * Determine when the file was last modified.
191   *
192   * @return the timestamp in milliseconds since UNIX epoch that the file was last modified at, or
193   *     {@code 0L} if unmodified.
194   */
195  @Override
196  public long getLastModified() {
197    try {
198      return Files.getLastModifiedTime(fullPath).toMillis();
199    } catch (IOException ex) {
200      LOGGER.debug("Ignoring error reading last modified time for {}", uri, ex);
201      return NOT_MODIFIED;
202    }
203  }
204
205  /**
206   * Get the location of this path file object.
207   *
208   * @return the location.
209   */
210  public Location getLocation() {
211    return location;
212  }
213
214  /**
215   * Get the file name as a string.
216   *
217   * @return the name of the file.
218   */
219  @Override
220  public String getName() {
221    return name;
222  }
223
224  /**
225   * Determine the class nesting kind, where appropriate.
226   *
227   * <p>In this implementation, this class will always return {@code null}, since this
228   * information is not readily available without preloading the file in question and parsing it
229   * first.
230   *
231   * <p>At the time of writing, the OpenJDK implementations of the JavaFileObject class
232   * do not provide an implementation for this method either.
233   *
234   * @return {@code null} in all cases, this operation is not implemented.
235   */
236  @Override
237  public NestingKind getNestingKind() {
238    return unknown();
239  }
240
241  /**
242   * Get the relative path of this file object.
243   *
244   * @return the path of this file object.
245   */
246  public Path getRelativePath() {
247    return relativePath;
248  }
249
250  /**
251   * Get the root path that the package containing this file is nested within.
252   *
253   * @return the root path.
254   */
255  public Path getRootPath() {
256    return rootPath;
257  }
258
259  /**
260   * Determine the hash code for this object.
261   *
262   * @return the hash code for the object.
263   */
264  @Override
265  public int hashCode() {
266    // Corresponds to what the .equals override checks for.
267    return uri.hashCode();
268  }
269
270  /**
271   * Determine if a given simple name and file kind are compatible with this file object.
272   *
273   * <p>This will perform a case-sensitive check, regardless of the platform that it runs on.
274   *
275   * @return {@code true} if the simple name and kind are compatible with the current file object
276   *     name, or {@code false} if not.
277   */
278  @Override
279  public boolean isNameCompatible(String simpleName, Kind kind) {
280    // Note that this behaves case-sensitively, even on Windows.
281    var fileName = simpleName + kind.extension;
282
283    return relativePath.getFileName().toString().equals(fileName);
284  }
285
286  /**
287   * Open an input stream into this file.
288   *
289   * <p>This input stream must be closed once finished with, otherwise
290   * resources will be leaked.
291   *
292   * <p>The returned implementation will always be buffered.
293   *
294   * @return a buffered input stream.
295   * @throws NoSuchFileException if the file does not exist.
296   * @throws IOException         if an IO error occurs.
297   */
298  @Override
299  public BufferedInputStream openInputStream() throws IOException {
300    return new BufferedInputStream(openUnbufferedInputStream());
301  }
302
303  /**
304   * Open an output stream to this file.
305   *
306   * <p>This will create the file first if it does not already exist. If it does
307   * exist, then this will overwrite the file and truncate it. The parent directories
308   * will also be created if they do not exist.
309   *
310   * <p>This output stream must be closed once finished with, otherwise
311   * resources will be leaked.
312   *
313   * <p>The returned implementation will always be buffered.
314   *
315   * @return a buffered output stream.
316   * @throws IOException if an IO error occurs.
317   */
318  @Override
319  public BufferedOutputStream openOutputStream() throws IOException {
320    return new BufferedOutputStream(openUnbufferedOutputStream());
321  }
322
323  /**
324   * Open a reader to this file using the default charset (UTF-8).
325   *
326   * <p>This reader must be closed once finished with, otherwise
327   * resources will be leaked.
328   *
329   * <p>The returned implementation will always be buffered.
330   *
331   * @param ignoreEncodingErrors {@code true} to suppress encoding errors, or {@code false} to throw
332   *                             them to the caller.
333   * @return a buffered reader.
334   * @throws NoSuchFileException if the file does not exist.
335   * @throws IOException         if an IO error occurs.
336   */
337  @Override
338  public BufferedReader openReader(boolean ignoreEncodingErrors) throws IOException {
339    var inputStream = openUnbufferedInputStream();
340    var decoder = decoder(ignoreEncodingErrors);
341    var reader = new InputStreamReader(inputStream, decoder);
342    return new BufferedReader(reader);
343  }
344
345  /**
346   * Open a writer to this file using the default charset (UTF-8).
347   *
348   * <p>This will create the file first if it does not already exist. If it does exist,
349   * this will first overwrite the file and truncate it. The parent directories
350   * will also be created if they do not exist.
351   *
352   * <p>This input stream must be closed once finished with, otherwise
353   * resources will be leaked.
354   *
355   * <p>The returned implementation will always be buffered.
356   *
357   * @return a buffered writer.
358   * @throws IOException if an IO error occurs.
359   */
360  @Override
361  public BufferedWriter openWriter() throws IOException {
362    var outputStream = openUnbufferedOutputStream();
363    var encoder = CHARSET
364        .newEncoder()
365        .onUnmappableCharacter(CodingErrorAction.REPORT)
366        .onMalformedInput(CodingErrorAction.REPORT);
367    var writer = new OutputStreamWriter(outputStream, encoder);
368    return new BufferedWriter(writer);
369  }
370
371  /**
372   * Determine the URI for this file.
373   *
374   * @return the URI for this file object.
375   */
376  @Override
377  public URI toUri() {
378    return uri;
379  }
380
381  /**
382   * Get a string representation of this object.
383   *
384   * @return a string representation of this object.
385   */
386  @Override
387  public String toString() {
388    return new ToStringBuilder(this)
389        .attribute("uri", uri)
390        .toString();
391  }
392
393  private InputStream openUnbufferedInputStream() throws IOException {
394    return Files.newInputStream(fullPath);
395  }
396
397  private OutputStream openUnbufferedOutputStream() throws IOException {
398    // Ensure parent directories exist first.
399    Files.createDirectories(fullPath.getParent());
400    return Files.newOutputStream(fullPath);
401  }
402
403  private CharsetDecoder decoder(boolean ignoreEncodingErrors) {
404    var action = ignoreEncodingErrors
405        ? CodingErrorAction.IGNORE
406        : CodingErrorAction.REPORT;
407
408    return CHARSET
409        .newDecoder()
410        .onUnmappableCharacter(action)
411        .onMalformedInput(action);
412  }
413
414  private <T> T unknown() {
415    return null;
416  }
417}