089e23005b4812ab7576617056e7fc9c68d6c29f
[idea/community.git] / platform / util / src / com / intellij / util / EnvironmentUtil.java
1 /*
2  * Copyright 2000-2015 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.util;
17
18 import com.intellij.execution.process.UnixProcessManager;
19 import com.intellij.openapi.application.PathManager;
20 import com.intellij.openapi.diagnostic.Logger;
21 import com.intellij.openapi.util.AtomicNotNullLazyValue;
22 import com.intellij.openapi.util.NotNullLazyValue;
23 import com.intellij.openapi.util.SystemInfo;
24 import com.intellij.openapi.util.io.FileUtil;
25 import com.intellij.openapi.util.registry.Registry;
26 import com.intellij.openapi.util.text.StringUtil;
27 import com.intellij.openapi.vfs.CharsetToolkit;
28 import com.intellij.util.concurrency.FixedFuture;
29 import com.intellij.util.text.CaseInsensitiveStringHashingStrategy;
30 import gnu.trove.THashMap;
31 import org.jetbrains.annotations.NotNull;
32 import org.jetbrains.annotations.Nullable;
33 import org.jetbrains.annotations.TestOnly;
34
35 import java.io.*;
36 import java.nio.charset.Charset;
37 import java.util.*;
38 import java.util.concurrent.*;
39
40 import static java.util.Collections.unmodifiableMap;
41
42 public class EnvironmentUtil {
43   private static final Logger LOG = Logger.getInstance("#com.intellij.util.EnvironmentUtil");
44
45   private static final int SHELL_ENV_READING_TIMEOUT = 20000;
46
47   private static final String LANG = "LANG";
48   private static final String LC_ALL = "LC_ALL";
49   private static final String LC_CTYPE = "LC_CTYPE";
50
51   private static final Future<Map<String, String>> ourEnvGetter;
52   static {
53     if (SystemInfo.isMac && "unlocked".equals(System.getProperty("__idea.mac.env.lock")) && Registry.is("idea.fix.mac.env")) {
54       ExecutorService executor = Executors.newSingleThreadExecutor(ConcurrencyUtil.newNamedThreadFactory("Shell Env Loader"));
55       ourEnvGetter = executor.submit(new Callable<Map<String, String>>() {
56         @Override
57         public Map<String, String> call() throws Exception {
58           return unmodifiableMap(setCharsetVar(getShellEnv()));
59         }
60       });
61       executor.shutdown();
62     }
63     else {
64       ourEnvGetter = new FixedFuture<Map<String, String>>(getSystemEnv());
65     }
66   }
67
68   private static final NotNullLazyValue<Map<String, String>> ourEnvironment = new AtomicNotNullLazyValue<Map<String, String>>() {
69     @NotNull
70     @Override
71     protected Map<String, String> compute() {
72       try {
73         return ourEnvGetter.get();
74       }
75       catch (Throwable t) {
76         LOG.warn("can't get shell environment", t);
77         return getSystemEnv();
78       }
79     }
80   };
81
82   private static Map<String, String> getSystemEnv() {
83     if (SystemInfo.isWindows) {
84       return unmodifiableMap(new THashMap<String, String>(System.getenv(), CaseInsensitiveStringHashingStrategy.INSTANCE));
85     }
86     else {
87       return System.getenv();
88     }
89   }
90
91   private EnvironmentUtil() { }
92
93   public static boolean isEnvironmentReady() {
94     return ourEnvGetter.isDone();
95   }
96
97   /**
98    * A wrapper layer around {@link System#getenv()}.
99    * <p>
100    * On Windows, the returned map is case-insensitive (i.e. {@code map.get("Path") == map.get("PATH")} holds).
101    * <p>
102    * On Mac OS X things are complicated.<br/>
103    * An app launched by a GUI launcher (Finder, Dock, Spotlight etc.) receives a pretty empty and useless environment,
104    * since standard Unix ways of setting variables via e.g. ~/.profile do not work. What's more important, there are no
105    * sane alternatives. This causes a lot of user complaints about tools working in a terminal not working when launched
106    * from the IDE. To ease their pain, the IDE loads a shell environment (see {@link #getShellEnv()} for gory details)
107    * and returns it as the result.<br/>
108    * And one more thing (c): locale variables on OS X are usually set by a terminal app - meaning they are missing
109    * even from a shell environment above. This again causes user complaints about tools being unable to output anything
110    * outside ASCII range when launched from the IDE. Resolved by adding LC_CTYPE variable to the map if it doesn't contain
111    * explicitly set locale variables (LANG/LC_ALL/LC_CTYPE). See {@link #setCharsetVar(Map)} for details.
112    *
113    * @return unmodifiable map of the process environment.
114    */
115   @NotNull
116   public static Map<String, String> getEnvironmentMap() {
117     return ourEnvironment.getValue();
118   }
119
120   /**
121    * Same as {@code getEnvironmentMap().get(name)}.
122    * Returns value for the passed environment variable name, or null if no such variable found.
123    *
124    * @see #getEnvironmentMap()
125    */
126   @Nullable
127   public static String getValue(@NotNull String name) {
128     return getEnvironmentMap().get(name);
129   }
130
131   /**
132    * Same as {@code flattenEnvironment(getEnvironmentMap())}.
133    * Returns an environment as an array of "NAME=VALUE" strings.
134    *
135    * @see #getEnvironmentMap()
136    */
137   public static String[] getEnvironment() {
138     return flattenEnvironment(getEnvironmentMap());
139   }
140
141   public static String[] flattenEnvironment(Map<String, String> environment) {
142     String[] array = new String[environment.size()];
143     int i = 0;
144     for (Map.Entry<String, String> entry : environment.entrySet()) {
145       array[i++] = entry.getKey() + "=" + entry.getValue();
146     }
147     return array;
148   }
149
150   private static Map<String, String> getShellEnv() throws Exception {
151     String shell = System.getenv("SHELL");
152     if (shell == null || !new File(shell).canExecute()) {
153       throw new Exception("shell:" + shell);
154     }
155
156     File reader = FileUtil.findFirstThatExist(
157       PathManager.getBinPath() + "/printenv.py",
158       PathManager.getHomePath() + "/community/bin/mac/printenv.py",
159       PathManager.getHomePath() + "/bin/mac/printenv.py"
160     );
161     if (reader == null) {
162       throw new Exception("bin:" + PathManager.getBinPath());
163     }
164
165     File envFile = FileUtil.createTempFile("intellij-shell-env.", ".tmp", false);
166     try {
167       String[] command = {shell, "-l", "-i", "-c", ("'" + reader.getAbsolutePath() + "' '" + envFile.getAbsolutePath() + "'")};
168       LOG.info("loading shell env: " + StringUtil.join(command, " "));
169
170       Process process = Runtime.getRuntime().exec(command);
171       StreamGobbler stdoutGobbler = new StreamGobbler(process.getInputStream(), "stdout");
172       stdoutGobbler.start();
173       StreamGobbler stderrGobbler = new StreamGobbler(process.getErrorStream(), "stderr");
174       stderrGobbler.start();
175       int rv = waitAndTerminateAfter(process, SHELL_ENV_READING_TIMEOUT);
176       stdoutGobbler.logIfNotEmpty();
177       stderrGobbler.logIfNotEmpty();
178
179       String lines = FileUtil.loadFile(envFile);
180       if (rv != 0 || lines.isEmpty()) {
181         throw new Exception("rv:" + rv + " text:" + lines.length());
182       }
183       return parseEnv(lines);
184     }
185     finally {
186       FileUtil.delete(envFile);
187     }
188   }
189
190   private static Map<String, String> parseEnv(String text) throws Exception {
191     Set<String> toIgnore = new HashSet<String>(Arrays.asList("_", "PWD", "SHLVL"));
192     Map<String, String> env = System.getenv();
193     Map<String, String> newEnv = new HashMap<String, String>();
194
195     String[] lines = text.split("\0");
196     for (String line : lines) {
197       int pos = line.indexOf('=');
198       if (pos <= 0) {
199         throw new Exception("malformed:" + line);
200       }
201       String name = line.substring(0, pos);
202       if (!toIgnore.contains(name)) {
203         newEnv.put(name, line.substring(pos + 1));
204       }
205       else if (env.containsKey(name)) {
206         newEnv.put(name, env.get(name));
207       }
208     }
209
210     LOG.info("shell environment loaded (" + newEnv.size() + " vars)");
211     return newEnv;
212   }
213
214   private static int waitAndTerminateAfter(@NotNull Process process, int timeoutMillis) {
215     Integer exitCode = waitFor(process, timeoutMillis);
216     if (exitCode != null) {
217       return exitCode;
218     }
219     LOG.warn("shell env loader is timed out");
220     UnixProcessManager.sendSigIntToProcessTree(process);
221     exitCode = waitFor(process, 1000);
222     if (exitCode != null) {
223       return exitCode;
224     }
225     LOG.warn("failed to terminate shell env loader process gracefully, terminating forcibly");
226     UnixProcessManager.sendSigKillToProcessTree(process);
227     exitCode = waitFor(process, 1000);
228     if (exitCode != null) {
229       return exitCode;
230     }
231     LOG.warn("failed to kill shell env loader");
232     return -1;
233   }
234
235   @Nullable
236   private static Integer waitFor(@NotNull Process process, int timeoutMillis) {
237     long stop = System.currentTimeMillis() + timeoutMillis;
238     while (System.currentTimeMillis() < stop) {
239       TimeoutUtil.sleep(100);
240       try {
241         return process.exitValue();
242       }
243       catch (IllegalThreadStateException ignore) { }
244     }
245     return null;
246   }
247
248   private static Map<String, String> setCharsetVar(@NotNull Map<String, String> env) {
249     if (!isCharsetVarDefined(env)) {
250       Locale locale = Locale.getDefault();
251       Charset charset = CharsetToolkit.getDefaultSystemCharset();
252       String language = locale.getLanguage();
253       String country = locale.getCountry();
254       String value = (language.isEmpty() || country.isEmpty() ? "en_US" : language + '_' + country) + '.' + charset.name();
255       env.put(LC_CTYPE, value);
256       LOG.info("LC_CTYPE=" + value);
257     }
258     return env;
259   }
260
261   private static boolean isCharsetVarDefined(@NotNull Map<String, String> env) {
262     return !env.isEmpty() && (env.containsKey(LANG) || env.containsKey(LC_ALL) || env.containsKey(LC_CTYPE));
263   }
264
265   public static void inlineParentOccurrences(@NotNull Map<String, String> envs) {
266     Map<String, String> parentParams = new HashMap<String, String>(System.getenv());
267     for (Map.Entry<String, String> entry : envs.entrySet()) {
268       String key = entry.getKey();
269       String value = entry.getValue();
270       if (value != null) {
271         String parentVal = parentParams.get(key);
272         if (parentVal != null && containsEnvKeySubstitution(key, value)) {
273           envs.put(key, value.replace("$" + key + "$", parentVal));
274         }
275       }
276     }
277   }
278
279   private static boolean containsEnvKeySubstitution(final String envKey, final String val) {
280     return ArrayUtil.find(val.split(File.pathSeparator), "$" + envKey + "$") != -1;
281   }
282
283   @TestOnly
284   static Map<String, String> testLoader() {
285     try {
286       return getShellEnv();
287     }
288     catch (Exception e) {
289       throw new RuntimeException(e);
290     }
291   }
292
293   @TestOnly
294   static Map<String, String> testParser(@NotNull String lines) {
295     try {
296       return parseEnv(lines);
297     }
298     catch (Exception e) {
299       throw new RuntimeException(e);
300     }
301   }
302
303   private static class StreamGobbler implements Runnable {
304     private final BufferedReader myReader;
305     private final String myStreamType;
306     private final StringBuffer myBuffer = new StringBuffer();
307     private final Semaphore myFinishSemaphore = new Semaphore(0);
308
309     public StreamGobbler(@NotNull InputStream stream, @NotNull String streamType) {
310       myReader = new BufferedReader(new InputStreamReader(stream));
311       myStreamType = streamType;
312     }
313
314     public void start() {
315       Thread thread = new Thread(this, "shell process " + myStreamType + " reader");
316       thread.start();
317     }
318
319     @Override
320     public void run() {
321       int ch;
322       try {
323         while ((ch = myReader.read()) != -1) {
324           myBuffer.append((char)ch);
325         }
326       }
327       catch (IOException e) {
328         LOG.warn("Error reading shell process " + myStreamType, e);
329       }
330       finally {
331         myFinishSemaphore.release(1);
332         close();
333       }
334     }
335
336     private void close() {
337       try {
338         myReader.close();
339       }
340       catch (IOException e) {
341         LOG.warn("Error closing shell process " + myStreamType, e);
342       }
343     }
344
345     @NotNull
346     private String getText() {
347       try {
348         if (!myFinishSemaphore.tryAcquire(1, 2, TimeUnit.SECONDS)) {
349           LOG.warn("closing shell process " + myStreamType + " forcibly");
350           close();
351         }
352       }
353       catch (InterruptedException e) {
354         LOG.info(e);
355       }
356       return myBuffer.toString();
357     }
358
359     public void logIfNotEmpty() {
360       String text = getText();
361       if (!text.isEmpty()) {
362         LOG.info("shell process " + myStreamType + ":" + StringUtil.trimEnd(text, '\n'));
363       }
364     }
365   }
366 }