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}