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}