d82d030c83474bc5f393fbabe5eac1e0eda452da
[idea/community.git] / platform / core-impl / src / com / intellij / openapi / fileEditor / impl / LoadTextUtil.java
1 /*
2  * Copyright 2000-2016 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.intellij.openapi.fileEditor.impl;
17
18 import com.intellij.openapi.diagnostic.Logger;
19 import com.intellij.openapi.fileTypes.BinaryFileDecompiler;
20 import com.intellij.openapi.fileTypes.BinaryFileTypeDecompilers;
21 import com.intellij.openapi.fileTypes.CharsetUtil;
22 import com.intellij.openapi.fileTypes.FileType;
23 import com.intellij.openapi.project.Project;
24 import com.intellij.openapi.util.Key;
25 import com.intellij.openapi.util.Pair;
26 import com.intellij.openapi.util.Trinity;
27 import com.intellij.openapi.util.text.StringUtil;
28 import com.intellij.openapi.vfs.CharsetToolkit;
29 import com.intellij.openapi.vfs.VirtualFile;
30 import com.intellij.openapi.vfs.encoding.EncodingManager;
31 import com.intellij.openapi.vfs.encoding.EncodingRegistry;
32 import com.intellij.testFramework.LightVirtualFile;
33 import com.intellij.util.ArrayUtil;
34 import com.intellij.util.ObjectUtils;
35 import com.intellij.util.text.CharArrayUtil;
36 import org.jetbrains.annotations.Nls;
37 import org.jetbrains.annotations.NotNull;
38 import org.jetbrains.annotations.Nullable;
39
40 import java.io.IOException;
41 import java.nio.ByteBuffer;
42 import java.nio.CharBuffer;
43 import java.nio.charset.Charset;
44 import java.nio.charset.UnsupportedCharsetException;
45
46 public final class LoadTextUtil {
47   private static final Logger LOG = Logger.getInstance("#com.intellij.openapi.fileEditor.impl.LoadTextUtil");
48   @Nls private static final String AUTO_DETECTED_FROM_BOM = "auto-detected from BOM";
49
50   private LoadTextUtil() { }
51
52   @NotNull
53   private static Pair<CharSequence, String> convertLineSeparators(@NotNull CharBuffer buffer) {
54     int dst = 0;
55     char prev = ' ';
56     int crCount = 0;
57     int lfCount = 0;
58     int crlfCount = 0;
59
60     final int length = buffer.length();
61     final char[] bufferArray = CharArrayUtil.fromSequenceWithoutCopying(buffer);
62
63     for (int src = 0; src < length; src++) {
64       char c = bufferArray != null ? bufferArray[src]:buffer.charAt(src);
65       switch (c) {
66         case '\r':
67           if(bufferArray != null) bufferArray[dst++] = '\n';
68           else buffer.put(dst++, '\n');
69           crCount++;
70           break;
71         case '\n':
72           if (prev == '\r') {
73             crCount--;
74             crlfCount++;
75           }
76           else {
77             if(bufferArray != null) bufferArray[dst++] = '\n';
78             else buffer.put(dst++, '\n');
79             lfCount++;
80           }
81           break;
82         default:
83           if(bufferArray != null) bufferArray[dst++] = c;
84           else buffer.put(dst++, c);
85           break;
86       }
87       prev = c;
88     }
89
90     String detectedLineSeparator = null;
91     if (crlfCount > crCount && crlfCount > lfCount) {
92       detectedLineSeparator = "\r\n";
93     }
94     else if (crCount > lfCount) {
95       detectedLineSeparator = "\r";
96     }
97     else if (lfCount > 0) {
98       detectedLineSeparator = "\n";
99     }
100
101     CharSequence result;
102     if (buffer.length() == dst) {
103       result = buffer;
104     }
105     else {
106       // in Mac JDK CharBuffer.subSequence() signature differs from Oracle's
107       // more than that, the signature has changed between jd6 and jdk7,
108       // so use more generic CharSequence.subSequence() just in case
109       @SuppressWarnings("UnnecessaryLocalVariable") CharSequence seq = buffer;
110       result = seq.subSequence(0, dst);
111     }
112     return Pair.create(result, detectedLineSeparator);
113   }
114
115   @NotNull
116   private static Charset detectCharset(@NotNull VirtualFile virtualFile, @NotNull byte[] content, @NotNull FileType fileType) {
117     Charset charset = null;
118
119     String charsetName = fileType.getCharset(virtualFile, content);
120     Trinity<Charset,CharsetToolkit.GuessedEncoding, byte[]> guessed = guessFromContent(virtualFile, content, content.length);
121
122     Charset hardCodedCharset = guessed == null ? null : guessed.first;
123     if (charsetName != null) {
124       charset = CharsetToolkit.forName(charsetName);
125     }
126     else if (hardCodedCharset == null) {
127       Charset specifiedExplicitly = EncodingRegistry.getInstance().getEncoding(virtualFile, true);
128       if (specifiedExplicitly != null) {
129         charset = specifiedExplicitly;
130       }
131     }
132     else {
133       charset = hardCodedCharset;
134     }
135
136     if (charset == null) {
137       charset = EncodingRegistry.getInstance().getDefaultCharset();
138     }
139     virtualFile.setCharset(charset);
140     return charset;
141   }
142
143   @NotNull
144   public static Charset detectCharsetAndSetBOM(@NotNull VirtualFile virtualFile, @NotNull byte[] content) {
145     return doDetectCharsetAndSetBOM(virtualFile, content, true).getFirst();
146   }
147
148   @NotNull
149   private static Pair.NonNull<Charset, byte[]> doDetectCharsetAndSetBOM(@NotNull VirtualFile virtualFile, @NotNull byte[] content, boolean saveBOM) {
150     return doDetectCharsetAndSetBOM(virtualFile, content, saveBOM, virtualFile.getFileType());
151   }
152   @NotNull
153   private static Pair.NonNull<Charset, byte[]> doDetectCharsetAndSetBOM(@NotNull VirtualFile virtualFile, @NotNull byte[] content, boolean saveBOM, @NotNull FileType fileType) {
154     @NotNull Charset charset = virtualFile.isCharsetSet() ? virtualFile.getCharset() : detectCharset(virtualFile, content,fileType);
155     Pair.NonNull<Charset, byte[]> bomAndCharset = getCharsetAndBOM(content, charset);
156     final byte[] bom = bomAndCharset.second;
157     if (saveBOM && bom.length != 0) {
158       virtualFile.setBOM(bom);
159       setCharsetWasDetectedFromBytes(virtualFile, AUTO_DETECTED_FROM_BOM);
160     }
161     return bomAndCharset;
162   }
163
164   private static final boolean GUESS_UTF = Boolean.parseBoolean(System.getProperty("idea.guess.utf.encoding", "true"));
165
166   @Nullable("null means no luck, otherwise it's tuple(guessed encoding, hint about content if was unable to guess, BOM)")
167   public static Trinity<Charset, CharsetToolkit.GuessedEncoding, byte[]> guessFromContent(@NotNull VirtualFile virtualFile, @NotNull byte[] content, int length) {
168     Charset defaultCharset = ObjectUtils.notNull(EncodingManager.getInstance().getEncoding(virtualFile, true), CharsetToolkit.getDefaultSystemCharset());
169     CharsetToolkit toolkit = GUESS_UTF ? new CharsetToolkit(content, defaultCharset) : null;
170     String detectedFromBytes = null;
171     try {
172       if (GUESS_UTF) {
173         toolkit.setEnforce8Bit(true);
174         Charset charset = toolkit.guessFromBOM();
175         if (charset != null) {
176           detectedFromBytes = AUTO_DETECTED_FROM_BOM;
177           byte[] bom = ObjectUtils.notNull(CharsetToolkit.getMandatoryBom(charset), CharsetToolkit.UTF8_BOM);
178           return Trinity.create(charset, null, bom);
179         }
180         CharsetToolkit.GuessedEncoding guessed = toolkit.guessFromContent(length);
181         if (guessed == CharsetToolkit.GuessedEncoding.VALID_UTF8) {
182           detectedFromBytes = "auto-detected from bytes";
183           return Trinity.create(CharsetToolkit.UTF8_CHARSET, guessed, null); //UTF detected, ignore all directives
184         }
185         if (guessed == CharsetToolkit.GuessedEncoding.SEVEN_BIT) {
186           return Trinity.create(null, guessed, null);
187         }
188       }
189       return null;
190     }
191     finally {
192       setCharsetWasDetectedFromBytes(virtualFile, detectedFromBytes);
193     }
194   }
195
196   @NotNull
197   private static Pair.NonNull<Charset,byte[]> getCharsetAndBOM(@NotNull byte[] content, @NotNull Charset charset) {
198     if (charset.name().contains(CharsetToolkit.UTF8) && CharsetToolkit.hasUTF8Bom(content)) {
199       return Pair.createNonNull(charset, CharsetToolkit.UTF8_BOM);
200     }
201     try {
202       Charset fromBOM = CharsetToolkit.guessFromBOM(content);
203       if (fromBOM != null) {
204         return Pair.createNonNull(fromBOM, ObjectUtils.notNull(CharsetToolkit.getMandatoryBom(fromBOM), ArrayUtil.EMPTY_BYTE_ARRAY));
205       }
206     }
207     catch (UnsupportedCharsetException ignore) {
208     }
209
210     return Pair.createNonNull(charset, ArrayUtil.EMPTY_BYTE_ARRAY);
211   }
212
213   public static void changeLineSeparators(@Nullable Project project,
214                                           @NotNull VirtualFile file,
215                                           @NotNull String newSeparator,
216                                           @NotNull Object requestor) throws IOException
217   {
218     CharSequence currentText = getTextByBinaryPresentation(file.contentsToByteArray(), file, true, false);
219     String currentSeparator = detectLineSeparator(file, false);
220     if (newSeparator.equals(currentSeparator)) {
221       return;
222     }
223     String newText = StringUtil.convertLineSeparators(currentText.toString(), newSeparator);
224
225     file.setDetectedLineSeparator(newSeparator);
226     write(project, file, requestor, newText, -1);
227   }
228
229   /**
230    * Overwrites file with text and sets modification stamp and time stamp to the specified values.
231    * <p/>
232    * Normally you should not use this method.
233    *
234    * @param requestor            any object to control who called this method. Note that
235    *                             it is considered to be an external change if {@code requestor} is {@code null}.
236    *                             See {@link com.intellij.openapi.vfs.VirtualFileEvent#getRequestor}
237    * @param newModificationStamp new modification stamp or -1 if no special value should be set @return {@code Writer}
238    * @throws IOException if an I/O error occurs
239    * @see VirtualFile#getModificationStamp()
240    */
241   public static void write(@Nullable Project project,
242                            @NotNull VirtualFile virtualFile,
243                            @NotNull Object requestor,
244                            @NotNull String text,
245                            long newModificationStamp) throws IOException {
246     Charset existing = virtualFile.getCharset();
247     Pair.NonNull<Charset, byte[]> chosen = charsetForWriting(project, virtualFile, text, existing);
248     Charset charset = chosen.first;
249     byte[] buffer = chosen.second;
250     if (!charset.equals(existing)) {
251       virtualFile.setCharset(charset);
252     }
253     setDetectedFromBytesFlagBack(virtualFile, buffer);
254
255     virtualFile.setBinaryContent(buffer, newModificationStamp, -1, requestor);
256   }
257
258   @NotNull
259   private static Pair.NonNull<Charset, byte[]> charsetForWriting(@Nullable Project project,
260                                                          @NotNull VirtualFile virtualFile,
261                                                          @NotNull String text,
262                                                          @NotNull Charset existing) {
263     Charset specified = extractCharsetFromFileContent(project, virtualFile, text);
264     Pair.NonNull<Charset, byte[]> chosen = chooseMostlyHarmlessCharset(existing, specified, text);
265     Charset charset = chosen.first;
266
267     // in case of "UTF-16", OutputStreamWriter sometimes adds BOM on it's own.
268     // see http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6800103
269     byte[] bom = virtualFile.getBOM();
270     Charset fromBom = bom == null ? null : CharsetToolkit.guessFromBOM(bom);
271     if (fromBom != null && !fromBom.equals(charset)) {
272       chosen = Pair.createNonNull(fromBom, toBytes(text, fromBom));
273     }
274     return chosen;
275   }
276
277   private static void setDetectedFromBytesFlagBack(@NotNull VirtualFile virtualFile, @NotNull byte[] content) {
278     if (virtualFile.getBOM() == null) {
279       guessFromContent(virtualFile, content, content.length);
280     }
281     else {
282       // prevent file to be reloaded in other encoding after save with BOM
283       setCharsetWasDetectedFromBytes(virtualFile, AUTO_DETECTED_FROM_BOM);
284     }
285   }
286
287   @NotNull
288   public static Pair.NonNull<Charset, byte[]> chooseMostlyHarmlessCharset(@NotNull Charset existing, @NotNull Charset specified, @NotNull String text) {
289     try {
290       if (specified.equals(existing)) {
291         return Pair.createNonNull(specified, toBytes(text, existing));
292       }
293
294       byte[] out = isSupported(specified, text);
295       if (out != null) {
296         return Pair.createNonNull(specified, out); //if explicitly specified encoding is safe, return it
297       }
298       out = isSupported(existing, text);
299       if (out != null) {
300         return Pair.createNonNull(existing, out);   //otherwise stick to the old encoding if it's ok
301       }
302       return Pair.createNonNull(specified, toBytes(text, specified)); //if both are bad there is no difference
303     }
304     catch (RuntimeException e) {
305       return Pair.createNonNull(Charset.defaultCharset(), toBytes(text, null)); //if both are bad and there is no hope, use the default charset
306     }
307   }
308
309   @NotNull
310   private static byte[] toBytes(@NotNull String text, @Nullable Charset charset) throws RuntimeException {
311     //noinspection SSBasedInspection
312     return charset == null ? text.getBytes() : text.getBytes(charset);
313   }
314
315   @Nullable("null means not supported, otherwise it is converted byte stream")
316   private static byte[] isSupported(@NotNull Charset charset, @NotNull String str) {
317     try {
318       if (!charset.canEncode()) return null;
319       byte[] bytes = str.getBytes(charset);
320       if (!str.equals(new String(bytes, charset))) {
321         return null;
322       }
323
324       return bytes;
325     }
326     catch (Exception e) {
327       return null;//wow, some charsets throw NPE inside .getBytes() when unable to encode (JIS_X0212-1990)
328     }
329   }
330
331   @NotNull
332   public static Charset extractCharsetFromFileContent(@Nullable Project project, @NotNull VirtualFile virtualFile, @NotNull CharSequence text) {
333     return ObjectUtils.notNull(charsetFromContentOrNull(project, virtualFile, text), virtualFile.getCharset());
334   }
335
336   @Nullable("returns null if cannot determine from content")
337   public static Charset charsetFromContentOrNull(@Nullable Project project, @NotNull VirtualFile virtualFile, @NotNull CharSequence text) {
338     return CharsetUtil.extractCharsetFromFileContent(project, virtualFile, virtualFile.getFileType(), text);
339   }
340
341   @NotNull
342   public static CharSequence loadText(@NotNull final VirtualFile file) {
343     if (file instanceof LightVirtualFile) {
344       return ((LightVirtualFile)file).getContent();
345     }
346
347     if (file.isDirectory()) {
348       throw new AssertionError("'" + file.getPresentableUrl() + "' is a directory");
349     }
350
351     FileType fileType = file.getFileType();
352     if (fileType.isBinary()) {
353       final BinaryFileDecompiler decompiler = BinaryFileTypeDecompilers.INSTANCE.forFileType(fileType);
354       if (decompiler != null) {
355         CharSequence text = decompiler.decompile(file);
356         try {
357           StringUtil.assertValidSeparators(text);
358         }
359         catch (AssertionError e) {
360           LOG.error(e);
361         }
362         return text;
363       }
364
365       throw new IllegalArgumentException("Attempt to load text for binary file which doesn't have a decompiler plugged in: " +
366                                          file.getPresentableUrl() + ". File type: " + fileType.getName());
367     }
368
369     try {
370       byte[] bytes = file.contentsToByteArray();
371       return getTextByBinaryPresentation(bytes, file);
372     }
373     catch (IOException e) {
374       return ArrayUtil.EMPTY_CHAR_SEQUENCE;
375     }
376   }
377
378   @NotNull
379   public static CharSequence getTextByBinaryPresentation(@NotNull final byte[] bytes, @NotNull VirtualFile virtualFile) {
380     return getTextByBinaryPresentation(bytes, virtualFile, true, true);
381   }
382
383   @NotNull
384   public static CharSequence getTextByBinaryPresentation(@NotNull byte[] bytes,
385                                                          @NotNull VirtualFile virtualFile,
386                                                          boolean saveDetectedSeparators,
387                                                          boolean saveBOM) {
388     return getTextByBinaryPresentation(bytes, virtualFile, saveDetectedSeparators, saveBOM, virtualFile.getFileType());
389   }
390   @NotNull
391   public static CharSequence getTextByBinaryPresentation(@NotNull byte[] bytes,
392                                                          @NotNull VirtualFile virtualFile,
393                                                          boolean saveDetectedSeparators,
394                                                          boolean saveBOM,
395                                                          @NotNull FileType fileType) {
396     Pair.NonNull<Charset, byte[]> pair = doDetectCharsetAndSetBOM(virtualFile, bytes, saveBOM, fileType);
397     Charset charset = pair.getFirst();
398     byte[] bom = pair.getSecond();
399     int offset = bom.length;
400
401     Pair<CharSequence, String> result = convertBytes(bytes, charset, offset);
402     if (saveDetectedSeparators) {
403       virtualFile.setDetectedLineSeparator(result.getSecond());
404     }
405     return result.getFirst();
406   }
407
408   /**
409    * Get detected line separator, if the file never been loaded, is loaded if checkFile parameter is specified.
410    *
411    * @param file      the file to check
412    * @param checkFile if the line separator was not detected before, try to detect it
413    * @return the detected line separator or null
414    */
415   @Nullable
416   public static String detectLineSeparator(@NotNull VirtualFile file, boolean checkFile) {
417     String lineSeparator = getDetectedLineSeparator(file);
418     if (lineSeparator == null && checkFile) {
419       try {
420         getTextByBinaryPresentation(file.contentsToByteArray(), file);
421         lineSeparator = getDetectedLineSeparator(file);
422       }
423       catch (IOException e) {
424         // null will be returned
425       }
426     }
427     return lineSeparator;
428   }
429
430   static String getDetectedLineSeparator(@NotNull VirtualFile file) {
431     return file.getDetectedLineSeparator();
432   }
433
434   @NotNull
435   public static CharSequence getTextByBinaryPresentation(@NotNull byte[] bytes, @NotNull Charset charset) {
436     Pair.NonNull<Charset, byte[]> pair = getCharsetAndBOM(bytes, charset);
437     byte[] bom = pair.getSecond();
438     int offset = bom.length;
439
440     final Pair<CharSequence, String> result = convertBytes(bytes, pair.first, offset);
441     return result.getFirst();
442   }
443
444   // do not need to think about BOM here. it is processed outside
445   @NotNull
446   private static Pair<CharSequence, String> convertBytes(@NotNull byte[] bytes, @NotNull Charset charset, final int startOffset) {
447     ByteBuffer byteBuffer = ByteBuffer.wrap(bytes, startOffset, bytes.length - startOffset);
448
449     CharBuffer charBuffer;
450     try {
451       charBuffer = charset.decode(byteBuffer);
452     }
453     catch (Exception e) {
454       // esoteric charsets can throw any kind of exception
455       charBuffer = CharBuffer.wrap(ArrayUtil.EMPTY_CHAR_ARRAY);
456     }
457     return convertLineSeparators(charBuffer);
458   }
459
460   private static final Key<String> CHARSET_WAS_DETECTED_FROM_BYTES = Key.create("CHARSET_WAS_DETECTED_FROM_BYTES");
461   @Nullable("null if was not detected, otherwise the reason it was")
462   public static String wasCharsetDetectedFromBytes(@NotNull VirtualFile virtualFile) {
463     return virtualFile.getUserData(CHARSET_WAS_DETECTED_FROM_BYTES);
464   }
465
466   public static void setCharsetWasDetectedFromBytes(@NotNull VirtualFile virtualFile,
467                                                     @Nullable("null if was not detected, otherwise the reason it was") String reason) {
468     virtualFile.putUserData(CHARSET_WAS_DETECTED_FROM_BYTES, reason);
469   }
470 }