• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

devonfw / IDEasy / 28395566249

29 Jun 2026 06:57PM UTC coverage: 71.373% (+0.03%) from 71.347%
28395566249

Pull #2087

github

web-flow
Merge 9eb598ad3 into d0ad3d852
Pull Request #2087: #2068: Isolate Claude Code configuration per IDEasy project

4709 of 7298 branches covered (64.52%)

Branch coverage included in aggregate %.

12138 of 16306 relevant lines covered (74.44%)

3.15 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.38
cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java
1
package com.devonfw.tools.ide.process;
2

3
import java.io.BufferedReader;
4
import java.io.IOException;
5
import java.io.InputStream;
6
import java.io.InputStreamReader;
7
import java.lang.ProcessBuilder.Redirect;
8
import java.nio.charset.StandardCharsets;
9
import java.nio.file.Files;
10
import java.nio.file.Path;
11
import java.util.ArrayList;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Objects;
15
import java.util.concurrent.CompletableFuture;
16
import java.util.concurrent.ConcurrentLinkedQueue;
17
import java.util.function.Predicate;
18
import java.util.stream.Collectors;
19

20
import org.slf4j.Logger;
21
import org.slf4j.LoggerFactory;
22

23
import com.devonfw.tools.ide.cli.CliProcessException;
24
import com.devonfw.tools.ide.common.SystemPath;
25
import com.devonfw.tools.ide.context.IdeContext;
26
import com.devonfw.tools.ide.environment.VariableLine;
27
import com.devonfw.tools.ide.log.IdeLogLevel;
28
import com.devonfw.tools.ide.os.SystemInfoImpl;
29
import com.devonfw.tools.ide.os.WindowsPathSyntax;
30
import com.devonfw.tools.ide.util.FilenameUtil;
31
import com.devonfw.tools.ide.variable.IdeVariables;
32

33
/**
34
 * Implementation of {@link ProcessContext}.
35
 */
36
public class ProcessContextImpl implements ProcessContext {
37

38
  private static final Logger LOG = LoggerFactory.getLogger(ProcessContextImpl.class);
3✔
39

40
  private static final String PREFIX_USR_BIN_ENV = "/usr/bin/env ";
41
  private static final Predicate<Integer> EXIT_CODE_ACCEPTOR = rc -> rc == ProcessResult.SUCCESS;
10✔
42

43
  /** The owning {@link IdeContext}. */
44
  protected final IdeContext context;
45

46
  private final ProcessBuilder processBuilder;
47

48
  protected final List<String> arguments;
49

50
  protected Path executable;
51

52
  private String overriddenPath;
53

54
  private final List<Path> extraPathEntries;
55

56
  private ProcessErrorHandling errorHandling;
57

58
  private OutputListener outputListener;
59

60
  private Predicate<Integer> exitCodeAcceptor;
61

62
  /**
63
   * The constructor.
64
   *
65
   * @param context the owning {@link IdeContext}.
66
   */
67
  public ProcessContextImpl(IdeContext context) {
68

69
    super();
2✔
70
    this.context = context;
3✔
71
    this.processBuilder = new ProcessBuilder();
7✔
72
    this.errorHandling = ProcessErrorHandling.THROW_ERR;
3✔
73
    Map<String, String> environment = this.processBuilder.environment();
4✔
74
    for (VariableLine var : this.context.getVariables().collectExportedVariables()) {
13✔
75
      if (var.isExport()) {
3!
76
        environment.put(var.getName(), var.getValue());
7✔
77
      }
78
    }
1✔
79
    this.arguments = new ArrayList<>();
5✔
80
    this.extraPathEntries = new ArrayList<>();
5✔
81
    this.exitCodeAcceptor = EXIT_CODE_ACCEPTOR;
3✔
82
  }
1✔
83

84
  private ProcessContextImpl(ProcessContextImpl parent) {
85

86
    super();
×
87
    this.context = parent.context;
×
88
    this.processBuilder = parent.processBuilder;
×
89
    this.errorHandling = ProcessErrorHandling.THROW_ERR;
×
90
    this.arguments = new ArrayList<>();
×
91
    this.extraPathEntries = parent.extraPathEntries;
×
92
    this.exitCodeAcceptor = EXIT_CODE_ACCEPTOR;
×
93
  }
×
94

95
  @Override
96
  public ProcessContext errorHandling(ProcessErrorHandling handling) {
97

98
    Objects.requireNonNull(handling);
3✔
99
    this.errorHandling = handling;
3✔
100
    return this;
2✔
101
  }
102

103
  @Override
104
  public ProcessContext directory(Path directory) {
105

106
    if (directory != null) {
2!
107
      this.processBuilder.directory(directory.toFile());
7✔
108
    } else {
109
      LOG.debug(
×
110
          "Could not set the process builder's working directory! Directory of the current java process is used.");
111
    }
112

113
    return this;
2✔
114
  }
115

116
  @Override
117
  public ProcessContext executable(Path command) {
118

119
    if (!this.arguments.isEmpty()) {
4!
120
      throw new IllegalStateException("Arguments already present - did you forget to call run for previous call?");
×
121
    }
122

123
    this.executable = command;
3✔
124
    return this;
2✔
125
  }
126

127
  @Override
128
  public ProcessContext addArg(String arg) {
129

130
    this.arguments.add(arg);
5✔
131
    return this;
2✔
132
  }
133

134
  @Override
135
  public ProcessContext withEnvVar(String key, String value) {
136

137
    if (IdeVariables.PATH.getName().equals(key)) {
5!
138
      this.overriddenPath = value;
×
139
    } else {
140
      LOG.trace("Setting process environment variable {}={}", key, value);
5✔
141
      this.processBuilder.environment().put(key, value);
7✔
142
    }
143
    return this;
2✔
144
  }
145

146
  @Override
147
  public EnvironmentContext removeEnvVar(String key) {
148

149
    LOG.trace("Removing process environment variable {}", key);
4✔
150
    this.processBuilder.environment().remove(key);
6✔
151
    return this;
2✔
152
  }
153

154
  @Override
155
  public ProcessContext withPathEntry(Path path) {
156

157
    this.extraPathEntries.add(path);
5✔
158
    return this;
2✔
159
  }
160

161
  @Override
162
  public ProcessContext withExitCodeAcceptor(Predicate<Integer> exitCodeAcceptor) {
163

164
    this.exitCodeAcceptor = exitCodeAcceptor;
3✔
165
    return this;
2✔
166
  }
167

168
  @Override
169
  public ProcessContext createChild() {
170

171
    return new ProcessContextImpl(this);
×
172
  }
173

174
  @Override
175
  public void setOutputListener(OutputListener listener) {
176
    this.outputListener = listener;
3✔
177
  }
1✔
178

179
  @Override
180
  public ProcessResult run(ProcessMode processMode) {
181

182
    if (this.executable == null) {
3✔
183
      throw new IllegalStateException("Missing executable to run process!");
5✔
184
    }
185

186
    SystemPath systemPath = this.context.getPath();
4✔
187
    if ((this.overriddenPath != null) || !this.extraPathEntries.isEmpty()) {
7!
188
      systemPath = systemPath.withPath(this.overriddenPath, this.extraPathEntries);
7✔
189
    }
190
    String path = systemPath.toString();
3✔
191
    LOG.trace("Setting PATH for process execution of {} to {}", this.executable.getFileName(), path);
7✔
192
    this.executable = systemPath.findBinary(this.executable);
6✔
193
    this.processBuilder.environment().put(IdeVariables.PATH.getName(), path);
8✔
194
    List<String> args = new ArrayList<>(this.arguments.size() + 4);
9✔
195
    String interpreter = addExecutable(args);
4✔
196
    args.addAll(this.arguments);
5✔
197
    String command = createCommand();
3✔
198
    if (LOG.isDebugEnabled()) {
3!
199
      String message = createCommandMessage(interpreter, " ...");
5✔
200
      LOG.debug(message);
3✔
201
    }
202

203
    try {
204
      applyRedirects(processMode);
3✔
205
      if (processMode.isBackground()) {
3✔
206
        modifyArgumentsOnBackgroundProcess(processMode);
3✔
207
      }
208

209
      this.processBuilder.command(args);
5✔
210

211
      ConcurrentLinkedQueue<OutputMessage> output = new ConcurrentLinkedQueue<>();
4✔
212

213
      Process process = this.processBuilder.start();
4✔
214

215
      try {
216
        if (Redirect.PIPE == processMode.getRedirectOutput() || Redirect.PIPE == processMode.getRedirectError()) {
8!
217
          CompletableFuture<Void> outFut = readInputStream(process.getInputStream(), false, output);
6✔
218
          CompletableFuture<Void> errFut = readInputStream(process.getErrorStream(), true, output);
6✔
219
          if (Redirect.PIPE == processMode.getRedirectOutput()) {
4!
220
            outFut.get();
3✔
221
          }
222
          if (Redirect.PIPE == processMode.getRedirectError()) {
4!
223
            errFut.get();
3✔
224
          }
225
          if (this.outputListener != null) {
3✔
226
            for (OutputMessage msg : output) {
10✔
227
              this.outputListener.onOutput(msg.message(), msg.error());
7✔
228
            }
1✔
229
          }
230
        }
231

232
        int exitCode;
233

234
        if (processMode.isBackground()) {
3✔
235
          exitCode = ProcessResult.SUCCESS;
3✔
236
        } else {
237
          exitCode = process.waitFor();
3✔
238
        }
239

240
        List<OutputMessage> finalOutput = new ArrayList<>(output);
5✔
241
        boolean success = this.exitCodeAcceptor.test(exitCode);
6✔
242
        ProcessResult result = new ProcessResultImpl(this.executable.getFileName().toString(), command, exitCode, success, finalOutput);
12✔
243

244
        performLogging(result, exitCode, interpreter);
5✔
245

246
        return result;
4✔
247
      } finally {
248
        if (!processMode.isBackground()) {
3✔
249
          process.destroy();
2✔
250
        }
251
      }
252
    } catch (CliProcessException | IllegalStateException e) {
1✔
253
      // these exceptions are thrown from performLogOnError and we do not want to wrap them (see #593)
254
      throw e;
2✔
255
    } catch (Exception e) {
1✔
256
      String msg = e.getMessage();
3✔
257
      if ((msg == null) || msg.isEmpty()) {
5!
258
        msg = e.getClass().getSimpleName();
×
259
      }
260
      throw new IllegalStateException(createCommandMessage(interpreter, " failed: " + msg), e);
10✔
261
    } finally {
262
      this.arguments.clear();
3✔
263
    }
264
  }
265

266
  /**
267
   * Asynchronously and parallel reads {@link InputStream input stream} and stores it in {@link CompletableFuture}. Inspired by: <a href=
268
   * "https://stackoverflow.com/questions/14165517/processbuilder-forwarding-stdout-and-stderr-of-started-processes-without-blocki/57483714#57483714">StackOverflow</a>
269
   *
270
   * @param is {@link InputStream}.
271
   * @param errorStream to identify if the output came from stdout or stderr
272
   * @return {@link CompletableFuture}.
273
   */
274
  private static CompletableFuture<Void> readInputStream(InputStream is, boolean errorStream, ConcurrentLinkedQueue<OutputMessage> outputMessages) {
275

276
    return CompletableFuture.supplyAsync(() -> {
6✔
277

278
      try (InputStreamReader isr = new InputStreamReader(is); BufferedReader br = new BufferedReader(isr)) {
10✔
279

280
        String line;
281
        while ((line = br.readLine()) != null) {
5✔
282
          OutputMessage outputMessage = new OutputMessage(errorStream, line);
6✔
283
          outputMessages.add(outputMessage);
4✔
284
        }
1✔
285

286
        return null;
4✔
287
      } catch (Throwable e) {
1✔
288
        throw new RuntimeException("There was a problem while executing the program", e);
6✔
289
      }
290
    });
291
  }
292

293
  private String createCommand() {
294
    String cmd = this.executable.toString();
4✔
295
    StringBuilder sb = new StringBuilder(cmd.length() + this.arguments.size() * 4);
12✔
296
    sb.append(cmd);
4✔
297
    for (String arg : this.arguments) {
11✔
298
      sb.append(' ');
4✔
299
      sb.append(arg);
4✔
300
    }
1✔
301
    return sb.toString();
3✔
302
  }
303

304
  private String createCommandMessage(String interpreter, String suffix) {
305

306
    StringBuilder sb = new StringBuilder();
4✔
307
    sb.append("Running command '");
4✔
308
    sb.append(this.executable);
5✔
309
    sb.append("'");
4✔
310
    if (interpreter != null) {
2✔
311
      sb.append(" using ");
4✔
312
      sb.append(interpreter);
4✔
313
    }
314
    int size = this.arguments.size();
4✔
315
    if (size > 0) {
2✔
316
      sb.append(" with arguments");
4✔
317
      for (int i = 0; i < size; i++) {
7✔
318
        String arg = this.arguments.get(i);
6✔
319
        sb.append(" '");
4✔
320
        sb.append(arg);
4✔
321
        sb.append("'");
4✔
322
      }
323
    }
324
    sb.append(suffix);
4✔
325
    return sb.toString();
3✔
326
  }
327

328
  private String getSheBang(Path file) {
329

330
    try (InputStream in = Files.newInputStream(file)) {
5✔
331
      // "#!/usr/bin/env bash".length() = 19
332
      byte[] buffer = new byte[32];
3✔
333
      int read = in.read(buffer);
4✔
334
      if ((read > 2) && (buffer[0] == '#') && (buffer[1] == '!')) {
13!
335
        int start = 2;
2✔
336
        int end = 2;
2✔
337
        while (end < read) {
3!
338
          byte c = buffer[end];
4✔
339
          if ((c == '\n') || (c == '\r') || (c > 127)) {
9!
340
            break;
×
341
          } else if ((end == start) && (c == ' ')) {
6!
342
            start++;
×
343
          }
344
          end++;
1✔
345
        }
1✔
346
        String sheBang = new String(buffer, start, end - start, StandardCharsets.US_ASCII).trim();
11✔
347
        if (sheBang.startsWith(PREFIX_USR_BIN_ENV)) {
4✔
348
          sheBang = sheBang.substring(PREFIX_USR_BIN_ENV.length());
5✔
349
        }
350
        return sheBang;
4✔
351
      }
352
    } catch (IOException e) {
5!
353
      // ignore...
354
    }
1✔
355
    return null;
2✔
356
  }
357

358
  private String addExecutable(List<String> args) {
359

360
    String interpreter = null;
2✔
361
    String fileExtension = FilenameUtil.getExtension(this.executable.getFileName().toString());
6✔
362
    boolean isBashScript = "sh".equals(fileExtension);
4✔
363
    this.context.getFileAccess().makeExecutable(this.executable, true);
7✔
364
    if (!isBashScript) {
2✔
365
      String sheBang = getSheBang(this.executable);
5✔
366
      if (sheBang != null) {
2✔
367
        String cmd = sheBang;
2✔
368
        int lastSlash = cmd.lastIndexOf('/');
4✔
369
        if (lastSlash >= 0) {
2✔
370
          cmd = cmd.substring(lastSlash + 1);
6✔
371
        }
372
        if (cmd.equals("bash")) {
4!
373
          isBashScript = true;
2✔
374
        } else {
375
          // currently we do not support other interpreters...
376
        }
377
      }
378
    }
379
    if (isBashScript) {
2✔
380
      interpreter = "bash";
2✔
381
      args.add(this.context.findBashRequired().toString());
7✔
382
    }
383
    if ("msi".equalsIgnoreCase(fileExtension)) {
4!
384
      args.addFirst("/i");
×
385
      args.addFirst("msiexec");
×
386
    }
387
    args.add(this.executable.toString());
6✔
388
    return interpreter;
2✔
389
  }
390

391
  private void performLogging(ProcessResult result, int exitCode, String interpreter) {
392

393
    if (!result.isSuccessful() && (this.errorHandling != ProcessErrorHandling.NONE)) {
7!
394
      IdeLogLevel ideLogLevel = this.errorHandling.getLogLevel();
4✔
395
      String message = createCommandMessage(interpreter, "\nfailed with exit code " + exitCode + "!");
6✔
396

397
      LOG.atLevel(ideLogLevel.getSlf4jLevel()).log(message);
6✔
398
      result.log(ideLogLevel);
3✔
399

400
      if (this.errorHandling == ProcessErrorHandling.THROW_CLI) {
4!
401
        throw new CliProcessException(message, result);
×
402
      } else if (this.errorHandling == ProcessErrorHandling.THROW_ERR) {
4✔
403
        throw new IllegalStateException(message);
5✔
404
      }
405
    }
406
  }
1✔
407

408
  private void modifyArgumentsOnBackgroundProcess(ProcessMode processMode) {
409

410
    assert processMode.isBackground() : "Cannot handle non background process mode!";
4!
411

412
    Path bash = this.context.findBash();
4✔
413
    if (bash == null) {
2!
414
      LOG.warn("Cannot start background process via bash because no bash installation was found. Hence, output will be discarded.");
×
415
      this.processBuilder.redirectOutput(Redirect.DISCARD).redirectError(Redirect.DISCARD);
×
416
      return;
×
417
    }
418

419
    String commandToRunInBackground = buildCommandToRunInBackground();
3✔
420

421
    this.arguments.clear();
3✔
422
    this.arguments.add(bash.toString());
6✔
423
    this.arguments.add("-c");
5✔
424
    commandToRunInBackground += " & disown";
3✔
425
    this.arguments.add(commandToRunInBackground);
5✔
426

427
  }
1✔
428

429
  private void applyRedirects(ProcessMode processMode) {
430

431
    Redirect output = processMode.getRedirectOutput();
3✔
432
    Redirect error = processMode.getRedirectError();
3✔
433
    Redirect input = processMode.getRedirectInput();
3✔
434

435
    if (output != null) {
2!
436
      this.processBuilder.redirectOutput(output);
5✔
437
    }
438
    if (error != null) {
2!
439
      this.processBuilder.redirectError(error);
5✔
440
    }
441
    if (input != null) {
2✔
442
      this.processBuilder.redirectInput(input);
5✔
443
    }
444
  }
1✔
445

446
  private String buildCommandToRunInBackground() {
447

448
    if (this.context.getSystemInfo().isWindows()) {
5!
449

450
      StringBuilder stringBuilder = new StringBuilder();
×
451

452
      for (String argument : this.arguments) {
×
453

454
        if (SystemInfoImpl.INSTANCE.isWindows() && SystemPath.isValidWindowsPath(argument)) {
×
455
          argument = WindowsPathSyntax.MSYS.normalize(argument);
×
456
        }
457

458
        stringBuilder.append(argument);
×
459
        stringBuilder.append(" ");
×
460
      }
×
461
      return stringBuilder.toString().trim();
×
462
    } else {
463
      return this.arguments.stream().map(Object::toString).collect(Collectors.joining(" "));
10✔
464
    }
465
  }
466
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc