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