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