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}