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