Implement parsing Ninja targets together with variable expanding.

Closes #10236.

PiperOrigin-RevId: 281270814
diff --git a/src/main/java/com/google/devtools/build/lib/BUILD b/src/main/java/com/google/devtools/build/lib/BUILD
index d56e086..1a5477f 100644
--- a/src/main/java/com/google/devtools/build/lib/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/BUILD
@@ -701,6 +701,8 @@
     deps = [
         "//src/main/java/com/google/devtools/build/lib:util",
         "//src/main/java/com/google/devtools/build/lib/concurrent",
+        "//src/main/java/com/google/devtools/build/lib/vfs:pathfragment",
+        "//third_party:auto_value_value",
         "//third_party:guava",
         "//third_party:jsr305",
     ],
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/lexer/NinjaLexer.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/lexer/NinjaLexer.java
index 4c0af09..600eda4 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/lexer/NinjaLexer.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/lexer/NinjaLexer.java
@@ -43,8 +43,8 @@
   private NinjaLexerStep step;
   private final List<Pair<Integer, Integer>> ranges;
   private final List<NinjaToken> tokens;
-  /** Flag to give a hint that letters should be interpreted as text, not as identifier. */
-  private boolean expectTextUntilEol;
+  /** Flag to give a hint how letters should be interpreted (as text, identifier, path). */
+  private TextKind expectedTextKind = TextKind.IDENTIFIER;
 
   /** @param fragment fragment to do the lexing on */
   public NinjaLexer(ByteBufferFragment fragment) {
@@ -90,17 +90,17 @@
           step.forceError("Tabs are not allowed, use spaces.");
           return push(NinjaToken.ERROR);
         case '\r':
-          expectTextUntilEol = false;
+          expectedTextKind = TextKind.IDENTIFIER;
           step.processLineFeedNewLine();
           return push(NinjaToken.NEWLINE);
         case '\n':
-          expectTextUntilEol = false;
+          expectedTextKind = TextKind.IDENTIFIER;
           return push(NinjaToken.NEWLINE);
         case '#':
           step.skipComment();
           break;
         case '=':
-          if (expectTextUntilEol) {
+          if (TextKind.TEXT.equals(expectedTextKind)) {
             step.readText();
             return push(NinjaToken.TEXT);
           }
@@ -108,7 +108,7 @@
         case ':':
           return push(NinjaToken.COLON);
         case '|':
-          if (expectTextUntilEol) {
+          if (TextKind.TEXT.equals(expectedTextKind)) {
             step.readText();
             return push(NinjaToken.TEXT);
           }
@@ -129,20 +129,25 @@
           step.forceError("Bad $-escape (literal $ must be written as $$)");
           return push(NinjaToken.ERROR);
         default:
-          if (expectTextUntilEol) {
-            step.readText();
-            return push(NinjaToken.TEXT);
-          } else {
-            step.tryReadIdentifier();
-            if (step.getError() == null) {
-              byte[] bytes = step.getBytes();
-              NinjaToken keywordToken = KEYWORD_MAP.get(bytes[0]);
-              if (keywordToken != null && Arrays.equals(keywordToken.getBytes(), bytes)) {
-                return push(keywordToken);
+          switch (expectedTextKind) {
+            case TEXT:
+              step.readText();
+              return push(NinjaToken.TEXT);
+            case PATH:
+              step.readPath();
+              return push(NinjaToken.TEXT);
+            case IDENTIFIER:
+              step.tryReadIdentifier();
+              if (step.getError() == null) {
+                byte[] bytes = step.getBytes();
+                NinjaToken keywordToken = KEYWORD_MAP.get(bytes[0]);
+                if (keywordToken != null && Arrays.equals(keywordToken.getBytes(), bytes)) {
+                  return push(keywordToken);
+                }
               }
-            }
-            return push(NinjaToken.IDENTIFIER);
+              return push(NinjaToken.IDENTIFIER);
           }
+          throw new IllegalStateException();
       }
       if (step.canAdvance()) {
         step.ensureEnd();
@@ -193,9 +198,9 @@
     return Preconditions.checkNotNull(Iterables.getLast(ranges).getSecond());
   }
 
-  /** Give a hint that letters should be interpreted as text, not as identifier. */
-  public void expectTextUntilEol() {
-    this.expectTextUntilEol = true;
+  /** Give a hint how letters should be interpreted (as text, identifier, path). */
+  public void setExpectedTextKind(TextKind expectedTextKind) {
+    this.expectedTextKind = expectedTextKind;
   }
 
   /** Undo the previously read token. */
@@ -204,7 +209,7 @@
     ranges.remove(ranges.size() - 1);
     tokens.remove(tokens.size() - 1);
     step = new NinjaLexerStep(fragment, ranges.isEmpty() ? 0 : getLastEnd());
-    expectTextUntilEol = false;
+    expectedTextKind = TextKind.IDENTIFIER;
   }
 
   public String getError() {
@@ -214,4 +219,14 @@
   public ByteBufferFragment getFragment() {
     return fragment;
   }
+
+  /**
+   * Enum with variants of text fragments parsing: as identifier (most restricted set of symbols),
+   * path (all spaces should be $-escaped, and | symbol has a special meaning), or text.
+   */
+  public enum TextKind {
+    IDENTIFIER,
+    PATH,
+    TEXT
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/lexer/NinjaLexerStep.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/lexer/NinjaLexerStep.java
index c4d2f51..41fefb1 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/lexer/NinjaLexerStep.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/lexer/NinjaLexerStep.java
@@ -47,6 +47,7 @@
   private static final ImmutableSortedSet<Byte> IDENTIFIER_SYMBOLS =
       createByteSet("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-");
   private static final ImmutableSortedSet<Byte> TEXT_STOPPERS = createByteSet("\n\r \t#$:\u0000");
+  private static final ImmutableSortedSet<Byte> PATH_STOPPERS = createByteSet("\n\r \t#$:|\u0000");
 
   private static ImmutableSortedSet<Byte> createByteSet(String variants) {
     ImmutableSortedSet.Builder<Byte> builder = ImmutableSortedSet.naturalOrder();
@@ -238,6 +239,10 @@
     end = eatSequence(position, TEXT_STOPPERS::contains);
   }
 
+  public void readPath() {
+    end = eatSequence(position, PATH_STOPPERS::contains);
+  }
+
   private int readIdentifier(int startFrom, boolean withDot) {
     if (withDot) {
       return eatSequence(startFrom, b -> !IDENTIFIER_SYMBOLS.contains(b) && '.' != b);
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaParser.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaParser.java
index cd50c4a..74f00d4 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaParser.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaParser.java
@@ -20,11 +20,22 @@
 import com.google.common.base.Preconditions;
 import com.google.common.base.Supplier;
 import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
 import com.google.devtools.build.lib.bazel.rules.ninja.file.GenericParsingException;
 import com.google.devtools.build.lib.bazel.rules.ninja.lexer.NinjaLexer;
+import com.google.devtools.build.lib.bazel.rules.ninja.lexer.NinjaLexer.TextKind;
 import com.google.devtools.build.lib.bazel.rules.ninja.lexer.NinjaToken;
+import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaTarget.InputKind;
+import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaTarget.InputOutputKind;
+import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaTarget.OutputKind;
 import com.google.devtools.build.lib.util.Pair;
+import com.google.devtools.build.lib.vfs.PathFragment;
 import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 
 /** Ninja files parser. The types of tokens: {@link NinjaToken}. Ninja lexer: {@link NinjaLexer}. */
 public class NinjaParser {
@@ -39,24 +50,21 @@
     String name = asString(parseExpected(NinjaToken.IDENTIFIER));
     parseExpected(NinjaToken.EQUALS);
 
-    NinjaVariableValue value = parseVariableValue(true, name);
+    NinjaVariableValue value = parseVariableValue(name);
     return Pair.of(name, value);
   }
 
   @VisibleForTesting
-  public NinjaVariableValue parseVariableValue(boolean allowUnescapedColon, String name)
-      throws GenericParsingException {
-    return parseVariableValueImpl(
-        allowUnescapedColon, () -> String.format("Variable '%s' has no value.", name));
+  public NinjaVariableValue parseVariableValue(String name) throws GenericParsingException {
+    return parseVariableValueImpl(() -> String.format("Variable '%s' has no value.", name));
   }
 
-  private NinjaVariableValue parseVariableValueImpl(
-      boolean allowUnescapedColon, Supplier<String> messageForNoValue)
+  private NinjaVariableValue parseVariableValueImpl(Supplier<String> messageForNoValue)
       throws GenericParsingException {
     NinjaVariableValue.Builder varBuilder = NinjaVariableValue.builder();
     int previous = -1;
     while (lexer.hasNextToken()) {
-      lexer.expectTextUntilEol();
+      lexer.setExpectedTextKind(TextKind.TEXT);
       NinjaToken token = lexer.nextToken();
       if (NinjaToken.VARIABLE.equals(token)) {
         if (previous >= 0) {
@@ -67,7 +75,7 @@
         varBuilder.addVariable(normalizeVariableName(asString(lexer.getTokenBytes())));
       } else if (NinjaToken.TEXT.equals(token)
           || NinjaToken.ESCAPED_TEXT.equals(token)
-          || (allowUnescapedColon && NinjaToken.COLON.equals(token))) {
+          || NinjaToken.COLON.equals(token)) {
         // Add text together with the spaces between current and previous token.
         int start = previous >= 0 ? previous : lexer.getLastStart();
         String rawText = asString(lexer.getFragment().getBytes(start, lexer.getLastEnd()));
@@ -86,6 +94,41 @@
     return varBuilder.build();
   }
 
+  /**
+   * Paths variable is a sequence of text and variable references until space, newline, eof or |
+   * symbol.
+   */
+  @Nullable
+  private NinjaVariableValue parsePathVariableValue() {
+    NinjaVariableValue.Builder varBuilder = NinjaVariableValue.builder();
+    int previous = -1;
+    while (lexer.hasNextToken()) {
+      lexer.setExpectedTextKind(TextKind.PATH);
+      NinjaToken token = lexer.nextToken();
+      if (previous >= 0 && lexer.getLastStart() != previous) {
+        // no spaces.
+        lexer.undo();
+        break;
+      }
+      if (NinjaToken.VARIABLE.equals(token)) {
+        varBuilder.addVariable(normalizeVariableName(asString(lexer.getTokenBytes())));
+      } else if (NinjaToken.TEXT.equals(token) || NinjaToken.ESCAPED_TEXT.equals(token)) {
+        String rawText = asString(lexer.getTokenBytes());
+        String text = NinjaToken.ESCAPED_TEXT.equals(token) ? unescapeText(rawText) : rawText;
+        varBuilder.addText(text);
+      } else {
+        lexer.undo();
+        break;
+      }
+      previous = lexer.getLastEnd();
+    }
+    if (previous == -1) {
+      // We read no value.
+      return null;
+    }
+    return varBuilder.build();
+  }
+
   private static String unescapeText(String text) {
     StringBuilder sb = new StringBuilder(text.length());
     for (int i = 0; i < text.length(); i++) {
@@ -114,7 +157,6 @@
     parseExpected(token);
     NinjaVariableValue value =
         parseVariableValueImpl(
-            true,
             () -> String.format("%s statement has no path.", Ascii.toLowerCase(token.name())));
     if (lexer.hasNextToken()) {
       parseExpected(NinjaToken.NEWLINE);
@@ -137,7 +179,7 @@
       parseExpected(NinjaToken.INDENT);
       String variableName = asString(parseExpected(NinjaToken.IDENTIFIER));
       parseExpected(NinjaToken.EQUALS);
-      NinjaVariableValue value = parseVariableValue(true, variableName);
+      NinjaVariableValue value = parseVariableValue(variableName);
 
       NinjaRuleVariable ninjaRuleVariable = NinjaRuleVariable.nullOrValue(variableName);
       if (ninjaRuleVariable == null) {
@@ -151,6 +193,185 @@
     return new NinjaRule(variablesBuilder.build());
   }
 
+  private enum NinjaTargetParsingPart {
+    OUTPUTS(OutputKind.USUAL, true),
+    IMPLICIT_OUTPUTS(OutputKind.IMPLICIT, true),
+    INPUTS(InputKind.USUAL, false),
+    IMPLICIT_INPUTS(InputKind.IMPLICIT, false),
+    ORDER_ONLY_INPUTS(InputKind.ORDER_ONLY, false),
+    RULE_NAME(null, false),
+    VARIABLES(null, false);
+
+    @Nullable private final InputOutputKind inputOutputKind;
+    private final boolean transitionRequired;
+
+    NinjaTargetParsingPart(@Nullable InputOutputKind inputOutputKind, boolean transitionRequired) {
+      this.inputOutputKind = inputOutputKind;
+      this.transitionRequired = transitionRequired;
+    }
+
+    @Nullable
+    public InputOutputKind getInputOutputKind() {
+      return inputOutputKind;
+    }
+
+    public boolean isTransitionRequired() {
+      return transitionRequired;
+    }
+  }
+
+  /**
+   * Mapping for changing the {@link NinjaTargetParsingPart} according to the next separator symbol.
+   */
+  private static final ImmutableSortedMap<
+          NinjaTargetParsingPart, ImmutableSortedMap<NinjaToken, NinjaTargetParsingPart>>
+      TARGET_PARTS_TRANSITIONS_MAP =
+          ImmutableSortedMap.of(
+              NinjaTargetParsingPart.OUTPUTS,
+                  ImmutableSortedMap.of(
+                      NinjaToken.PIPE, NinjaTargetParsingPart.IMPLICIT_OUTPUTS,
+                      NinjaToken.COLON, NinjaTargetParsingPart.RULE_NAME),
+              NinjaTargetParsingPart.IMPLICIT_OUTPUTS,
+                  ImmutableSortedMap.of(NinjaToken.COLON, NinjaTargetParsingPart.RULE_NAME),
+              NinjaTargetParsingPart.INPUTS,
+                  ImmutableSortedMap.of(
+                      NinjaToken.PIPE, NinjaTargetParsingPart.IMPLICIT_INPUTS,
+                      NinjaToken.PIPE2, NinjaTargetParsingPart.ORDER_ONLY_INPUTS,
+                      NinjaToken.NEWLINE, NinjaTargetParsingPart.VARIABLES),
+              NinjaTargetParsingPart.IMPLICIT_INPUTS,
+                  ImmutableSortedMap.of(
+                      NinjaToken.PIPE2, NinjaTargetParsingPart.ORDER_ONLY_INPUTS,
+                      NinjaToken.NEWLINE, NinjaTargetParsingPart.VARIABLES),
+              NinjaTargetParsingPart.ORDER_ONLY_INPUTS,
+                  ImmutableSortedMap.of(NinjaToken.NEWLINE, NinjaTargetParsingPart.VARIABLES));
+
+  /**
+   * Parses Ninja target using {@link NinjaScope} of the file, where it is defined, to expand
+   * variables.
+   */
+  public NinjaTarget parseNinjaTarget(NinjaScope fileScope, int offset)
+      throws GenericParsingException {
+    NinjaTarget.Builder builder = NinjaTarget.builder();
+    parseExpected(NinjaToken.BUILD);
+
+    Map<InputOutputKind, List<NinjaVariableValue>> pathValuesMap =
+        parseTargetDependenciesPart(builder);
+
+    NinjaScope targetScope = parseTargetVariables(offset, fileScope, builder);
+
+    // Variables from the build statement can be used in the input and output paths, so
+    // we are using targetScope to resolve paths values.
+    for (Map.Entry<InputOutputKind, List<NinjaVariableValue>> entry : pathValuesMap.entrySet()) {
+      List<PathFragment> paths =
+          entry.getValue().stream()
+              .map(
+                  value ->
+                      PathFragment.create(targetScope.getExpandedValue(Integer.MAX_VALUE, value)))
+              .collect(Collectors.toList());
+      InputOutputKind inputOutputKind = entry.getKey();
+      if (inputOutputKind instanceof InputKind) {
+        builder.addInputs((InputKind) inputOutputKind, paths);
+      } else {
+        builder.addOutputs((OutputKind) inputOutputKind, paths);
+      }
+    }
+
+    return builder.build();
+  }
+
+  /**
+   * We resolve build statement variables values, using the file scope: build statement variable
+   * values can not refer to each other. Then we are constructing the target's {@link NinjaScope}
+   * with already expanded variables; it will be used for resolving target's input and output paths
+   * (which can also refer to file-level variables, so we better reuse resolve logic that we already
+   * have in NinjaScope).
+   *
+   * <p>As we expand variable values, we are adding them to {@link NinjaTarget.Builder}.
+   *
+   * <p>Ninja targets can not refer to the rule's variables values, because the rule variables are
+   * only expanded when the rule is used, and the rule is used for already parsed target. However,
+   * target's variables can override values of rule's variables.
+   *
+   * @return Ninja scope for expanding input and output paths of that statement
+   */
+  private NinjaScope parseTargetVariables(
+      int offset, NinjaScope fileScope, NinjaTarget.Builder builder)
+      throws GenericParsingException {
+    Map<String, List<Pair<Integer, String>>> expandedVariables = Maps.newHashMap();
+    while (lexer.hasNextToken()) {
+      parseExpected(NinjaToken.INDENT);
+
+      Pair<String, NinjaVariableValue> pair = parseVariable();
+      String name = Preconditions.checkNotNull(pair.getFirst());
+      NinjaVariableValue value = Preconditions.checkNotNull(pair.getSecond());
+      String expandedValue = fileScope.getExpandedValue(offset, value);
+      expandedVariables
+          .computeIfAbsent(name, k -> Lists.newArrayList())
+          .add(Pair.of(0, expandedValue));
+      builder.addVariable(name, expandedValue);
+
+      if (lexer.hasNextToken()) {
+        parseExpected(NinjaToken.NEWLINE);
+      }
+    }
+    return fileScope.createTargetsScope(ImmutableSortedMap.copyOf(expandedVariables));
+  }
+
+  /**
+   * Parse build statement dependencies part: output1..k [| implicit_output1..k]: rule input1..k [|
+   * implicit_input1..k] [|| order_only_input1..k]
+   */
+  private Map<InputOutputKind, List<NinjaVariableValue>> parseTargetDependenciesPart(
+      NinjaTarget.Builder builder) throws GenericParsingException {
+    Map<InputOutputKind, List<NinjaVariableValue>> pathValuesMap = Maps.newHashMap();
+    boolean ruleNameParsed = false;
+    NinjaTargetParsingPart parsingPart = NinjaTargetParsingPart.OUTPUTS;
+    while (lexer.hasNextToken() && !NinjaTargetParsingPart.VARIABLES.equals(parsingPart)) {
+      if (NinjaTargetParsingPart.RULE_NAME.equals(parsingPart)) {
+        ruleNameParsed = true;
+        builder.setRuleName(asString(parseExpected(NinjaToken.IDENTIFIER)));
+        parsingPart = NinjaTargetParsingPart.INPUTS;
+        continue;
+      }
+      List<NinjaVariableValue> paths = parsePaths();
+      if (paths.isEmpty() && !NinjaTargetParsingPart.INPUTS.equals(parsingPart)) {
+        throw new GenericParsingException("Expected paths sequence");
+      }
+      if (!paths.isEmpty()) {
+        pathValuesMap.put(Preconditions.checkNotNull(parsingPart.getInputOutputKind()), paths);
+      }
+      if (!lexer.hasNextToken()) {
+        if (parsingPart.isTransitionRequired()) {
+          throw new GenericParsingException("Unexpected end of target");
+        }
+        break;
+      }
+      NinjaToken lexicalSeparator = lexer.nextToken();
+      parsingPart =
+          Preconditions.checkNotNull(TARGET_PARTS_TRANSITIONS_MAP.get(parsingPart))
+              .get(lexicalSeparator);
+
+      if (parsingPart == null) {
+        throw new GenericParsingException("Unexpected token: " + lexicalSeparator);
+      }
+    }
+    if (!ruleNameParsed) {
+      throw new GenericParsingException("Expected rule name");
+    }
+    Preconditions.checkState(
+        !lexer.hasNextToken() || NinjaTargetParsingPart.VARIABLES.equals(parsingPart));
+    return pathValuesMap;
+  }
+
+  private List<NinjaVariableValue> parsePaths() {
+    List<NinjaVariableValue> result = Lists.newArrayList();
+    NinjaVariableValue value;
+    while (lexer.hasNextToken() && (value = parsePathVariableValue()) != null) {
+      result.add(value);
+    }
+    return result;
+  }
+
   @VisibleForTesting
   public static String normalizeVariableName(String raw) {
     // We start from 1 because it is always at least $ marker symbol in the beginning
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaScope.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaScope.java
index 91b8275..100cce5 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaScope.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaScope.java
@@ -19,6 +19,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -45,8 +46,9 @@
 
   private final NavigableMap<Integer, NinjaScope> includedScopes;
   private final Map<String, List<Pair<Integer, NinjaVariableValue>>> variables;
-  private final Map<String, List<Pair<Integer, String>>> expandedVariables;
+  private Map<String, List<Pair<Integer, String>>> expandedVariables;
   private final Map<String, List<Pair<Integer, NinjaRule>>> rules;
+  private boolean isExpanded;
 
   public NinjaScope() {
     this(null, null);
@@ -61,17 +63,38 @@
     rules = Maps.newHashMap();
   }
 
+  private NinjaScope(
+      @Nullable NinjaScope parentScope,
+      @Nullable Integer includePoint,
+      ImmutableSortedMap<String, List<Pair<Integer, String>>> expandedVariables) {
+    this.parentScope = parentScope;
+    this.includePoint = includePoint;
+    this.includedScopes = Maps.newTreeMap();
+    this.variables = Maps.newHashMap();
+    this.expandedVariables = expandedVariables;
+    this.rules = Maps.newHashMap();
+    this.isExpanded = true;
+  }
+
+  NinjaScope createTargetsScope(
+      ImmutableSortedMap<String, List<Pair<Integer, String>>> expandedVariables) {
+    return new NinjaScope(this, Integer.MAX_VALUE, expandedVariables);
+  }
+
   public NinjaScope createIncludeScope(int offset) {
+    Preconditions.checkState(!isExpanded);
     NinjaScope includeScope = new NinjaScope(this, offset);
     includedScopes.put(offset, includeScope);
     return includeScope;
   }
 
   public void addVariable(String name, int offset, NinjaVariableValue value) {
+    Preconditions.checkState(!isExpanded);
     variables.computeIfAbsent(name, k -> Lists.newArrayList()).add(Pair.of(offset, value));
   }
 
   public void addRule(int offset, NinjaRule rule) {
+    Preconditions.checkState(!isExpanded);
     rules.computeIfAbsent(rule.getName(), k -> Lists.newArrayList()).add(Pair.of(offset, rule));
   }
 
@@ -95,9 +118,15 @@
     }
   }
 
-  private void expandVariable(String name, int offset, NinjaVariableValue value) {
-    List<Pair<Integer, String>> targetList =
-        expandedVariables.computeIfAbsent(name, k -> Lists.newArrayList());
+  /**
+   * Expands variable value at the given offset. If some of the variable references, used in the
+   * value, can not be found, uses an empty string as their value.
+   *
+   * <p>This method is used either on already expanded scope, or in the process of the scope
+   * expansion in {@link #expandVariables}.
+   */
+  public String getExpandedValue(int offset, NinjaVariableValue value) {
+    Preconditions.checkState(isExpanded);
     // Cache expanded variables values to save time replacing several references to the same
     // variable.
     // This cache is local to the offset, it depends on the offset of the variable we are expanding.
@@ -106,18 +135,27 @@
     // Do the same as Ninja implementation: if the variable is not found, use empty string.
     Function<String, String> expander =
         ref -> cache.computeIfAbsent(ref, (key) -> nullToEmpty(findExpandedVariable(offset, key)));
-    targetList.add(Pair.of(offset, value.getExpandedValue(expander)));
+    return value.getExpandedValue(expander);
   }
 
   /** Resolve variables inside this scope and included scopes. */
   public void expandVariables() {
+    Preconditions.checkState(!isExpanded);
+    /* To allow assertion in {@link #getExpandedValue} to pass. */
+    isExpanded = true;
+
     TreeMap<Integer, Runnable> resolvables = Maps.newTreeMap();
     for (Map.Entry<String, List<Pair<Integer, NinjaVariableValue>>> entry : variables.entrySet()) {
       String name = entry.getKey();
       for (Pair<Integer, NinjaVariableValue> pair : entry.getValue()) {
         int offset = Preconditions.checkNotNull(pair.getFirst());
         NinjaVariableValue variableValue = Preconditions.checkNotNull(pair.getSecond());
-        resolvables.put(offset, () -> expandVariable(name, offset, variableValue));
+        resolvables.put(
+            offset,
+            () ->
+                expandedVariables
+                    .computeIfAbsent(name, k -> Lists.newArrayList())
+                    .add(Pair.of(offset, getExpandedValue(offset, variableValue))));
       }
     }
     for (Map.Entry<Integer, NinjaScope> entry : includedScopes.entrySet()) {
@@ -125,6 +163,8 @@
     }
 
     resolvables.values().forEach(Runnable::run);
+    expandedVariables = ImmutableSortedMap.copyOf(expandedVariables);
+    variables.clear();
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaTarget.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaTarget.java
new file mode 100644
index 0000000..4046918
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaTarget.java
@@ -0,0 +1,150 @@
+// Copyright 2019 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package com.google.devtools.build.lib.bazel.rules.ninja.parser;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.devtools.build.lib.collect.ImmutableSortedKeyListMultimap;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.errorprone.annotations.Immutable;
+import java.util.Collection;
+import java.util.List;
+
+/** Ninja target (build statement) representation. */
+public final class NinjaTarget {
+  /** Builder for {@link NinjaTarget}. */
+  public static class Builder {
+    private String ruleName;
+    private final ImmutableSortedKeyListMultimap.Builder<InputKind, PathFragment> inputsBuilder;
+    private final ImmutableSortedKeyListMultimap.Builder<OutputKind, PathFragment> outputsBuilder;
+
+    private final ImmutableSortedMap.Builder<String, String> variablesBuilder;
+
+    private Builder() {
+      inputsBuilder = ImmutableSortedKeyListMultimap.builder();
+      outputsBuilder = ImmutableSortedKeyListMultimap.builder();
+      variablesBuilder = ImmutableSortedMap.naturalOrder();
+    }
+
+    public Builder setRuleName(String ruleName) {
+      this.ruleName = ruleName;
+      return this;
+    }
+
+    public Builder addInputs(InputKind kind, Collection<PathFragment> inputs) {
+      inputsBuilder.putAll(kind, inputs);
+      return this;
+    }
+
+    public Builder addOutputs(OutputKind kind, Collection<PathFragment> outputs) {
+      outputsBuilder.putAll(kind, outputs);
+      return this;
+    }
+
+    public Builder addVariable(String key, String value) {
+      variablesBuilder.put(key, value);
+      return this;
+    }
+
+    public NinjaTarget build() {
+      Preconditions.checkNotNull(ruleName);
+      return new NinjaTarget(
+          ruleName, inputsBuilder.build(), outputsBuilder.build(), variablesBuilder.build());
+    }
+  }
+
+  /** Enum with possible kinds of inputs. */
+  @Immutable
+  public enum InputKind implements InputOutputKind {
+    USUAL,
+    IMPLICIT,
+    ORDER_ONLY
+  }
+
+  /** Enum with possible kinds of outputs. */
+  @Immutable
+  public enum OutputKind implements InputOutputKind {
+    USUAL,
+    IMPLICIT
+  }
+
+  /**
+   * Marker interface, so that it is possible to address {@link InputKind} and {@link OutputKind}
+   * together in one map.
+   */
+  @Immutable
+  public interface InputOutputKind {}
+
+  private final String ruleName;
+  private final ImmutableSortedKeyListMultimap<InputKind, PathFragment> inputs;
+  private final ImmutableSortedKeyListMultimap<OutputKind, PathFragment> outputs;
+  private final ImmutableSortedMap<String, String> variables;
+
+  public NinjaTarget(
+      String ruleName,
+      ImmutableSortedKeyListMultimap<InputKind, PathFragment> inputs,
+      ImmutableSortedKeyListMultimap<OutputKind, PathFragment> outputs,
+      ImmutableSortedMap<String, String> variables) {
+    this.ruleName = ruleName;
+    this.inputs = inputs;
+    this.outputs = outputs;
+    this.variables = variables;
+  }
+
+  public String getRuleName() {
+    return ruleName;
+  }
+
+  public ImmutableSortedMap<String, String> getVariables() {
+    return variables;
+  }
+
+  public boolean hasInputs() {
+    return !inputs.isEmpty();
+  }
+
+  public List<PathFragment> getOutputs() {
+    return outputs.get(OutputKind.USUAL);
+  }
+
+  public List<PathFragment> getImplicitOutputs() {
+    return outputs.get(OutputKind.IMPLICIT);
+  }
+
+  public Collection<PathFragment> getAllOutputs() {
+    return outputs.values();
+  }
+
+  public Collection<PathFragment> getAllInputs() {
+    return inputs.values();
+  }
+
+  public Collection<PathFragment> getUsualInputs() {
+    return inputs.get(InputKind.USUAL);
+  }
+
+  public Collection<PathFragment> getImplicitInputs() {
+    return inputs.get(InputKind.IMPLICIT);
+  }
+
+  public Collection<PathFragment> getOrderOnlyInputs() {
+    return inputs.get(InputKind.ORDER_ONLY);
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaVariableValue.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaVariableValue.java
index 8cc1a06..0431f92 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaVariableValue.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/parser/NinjaVariableValue.java
@@ -15,6 +15,7 @@
 
 package com.google.devtools.build.lib.bazel.rules.ninja.parser;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -46,6 +47,7 @@
   }
 
   /** Compute the expanded value, using the passed <code>expander</code> function. */
+  @VisibleForTesting
   public String getExpandedValue(Function<String, String> expander) {
     return parts.stream().map(fun -> fun.apply(expander)).collect(Collectors.joining(""));
   }
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/BUILD b/src/test/java/com/google/devtools/build/lib/bazel/BUILD
index 7f0c602..cbdfa21 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/bazel/BUILD
@@ -42,6 +42,7 @@
         "//src/main/java/com/google/devtools/build/lib:bazel-ninja",
         "//src/main/java/com/google/devtools/build/lib:util",
         "//src/main/java/com/google/devtools/build/lib/concurrent",
+        "//src/main/java/com/google/devtools/build/lib/vfs:pathfragment",
         "//src/test/java/com/google/devtools/build/lib:test_runner",
         "//src/test/java/com/google/devtools/build/lib:testutil",
         "//third_party:guava",
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaLexerTest.java b/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaLexerTest.java
index a40e9eb..88a50a1 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaLexerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaLexerTest.java
@@ -19,6 +19,7 @@
 
 import com.google.devtools.build.lib.bazel.rules.ninja.file.ByteBufferFragment;
 import com.google.devtools.build.lib.bazel.rules.ninja.lexer.NinjaLexer;
+import com.google.devtools.build.lib.bazel.rules.ninja.lexer.NinjaLexer.TextKind;
 import com.google.devtools.build.lib.bazel.rules.ninja.lexer.NinjaToken;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
@@ -144,7 +145,7 @@
     assertTokenBytes(lexer, NinjaToken.IDENTIFIER, "my.var");
     assertTokenBytes(lexer, NinjaToken.EQUALS, null);
 
-    lexer.expectTextUntilEol();
+    lexer.setExpectedTextKind(TextKind.TEXT);
     assertTokenBytes(lexer, NinjaToken.TEXT, "Any");
     assertTokenBytes(lexer, NinjaToken.TEXT, "text");
     assertTokenBytes(lexer, NinjaToken.TEXT, "^&%=@&!*");
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaParserTest.java b/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaParserTest.java
index b86a725..fecfbb9 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaParserTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaParserTest.java
@@ -26,8 +26,11 @@
 import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaParser;
 import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaRule;
 import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaRuleVariable;
+import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaScope;
+import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaTarget;
 import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaVariableValue;
 import com.google.devtools.build.lib.util.Pair;
+import com.google.devtools.build.lib.vfs.PathFragment;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.util.function.Function;
@@ -169,6 +172,104 @@
     doTestNinjaRuleParsingException("rule testRule\n custom = a", "Unexpected variable 'custom'");
   }
 
+  @Test
+  public void testNinjaTargets() throws GenericParsingException {
+    NinjaTarget target = parseNinjaTarget("build output: command input");
+    assertThat(target.getRuleName()).isEqualTo("command");
+    assertThat(target.getOutputs()).containsExactly(PathFragment.create("output"));
+    assertThat(target.getUsualInputs()).containsExactly(PathFragment.create("input"));
+
+    NinjaTarget target1 =
+        parseNinjaTarget("build o1 o2 | io1 io2: command i1 i2 | ii1 ii2 || ooi1 ooi2");
+    assertThat(target1.getRuleName()).isEqualTo("command");
+    assertThat(target1.getOutputs())
+        .containsExactly(PathFragment.create("o1"), PathFragment.create("o2"));
+    assertThat(target1.getImplicitOutputs())
+        .containsExactly(PathFragment.create("io1"), PathFragment.create("io2"));
+    assertThat(target1.getUsualInputs())
+        .containsExactly(PathFragment.create("i1"), PathFragment.create("i2"));
+    assertThat(target1.getImplicitInputs())
+        .containsExactly(PathFragment.create("ii1"), PathFragment.create("ii2"));
+    assertThat(target1.getOrderOnlyInputs())
+        .containsExactly(PathFragment.create("ooi1"), PathFragment.create("ooi2"));
+
+    NinjaTarget target2 = parseNinjaTarget("build output: phony");
+    assertThat(target2.getRuleName()).isEqualTo("phony");
+    assertThat(target2.getOutputs()).containsExactly(PathFragment.create("output"));
+
+    NinjaTarget target3 = parseNinjaTarget("build output: command $\n || order-only-input");
+    assertThat(target3.getRuleName()).isEqualTo("command");
+    assertThat(target3.getOutputs()).containsExactly(PathFragment.create("output"));
+    assertThat(target3.getOrderOnlyInputs())
+        .containsExactly(PathFragment.create("order-only-input"));
+  }
+
+  @Test
+  public void testNinjaTargetParsingErrors() {
+    testNinjaTargetParsingError("build xxx", "Unexpected end of target");
+    testNinjaTargetParsingError("build xxx yyy:", "Expected rule name");
+    testNinjaTargetParsingError("build xxx || yyy: command", "Unexpected token: PIPE2");
+    testNinjaTargetParsingError("build xxx: command :", "Unexpected token: COLON");
+    testNinjaTargetParsingError("build xxx: command | || a", "Expected paths sequence");
+  }
+
+  @Test
+  public void testNinjaTargetsWithVariables() throws GenericParsingException {
+    NinjaScope scope = new NinjaScope();
+    scope.addVariable("output", 1, NinjaVariableValue.createPlainText("out123"));
+    scope.addVariable("input", 2, NinjaVariableValue.createPlainText("in123"));
+
+    scope.expandVariables();
+
+    // Variables, defined inside build statement, are used for input and output paths,
+    // but not for the values of the other variables.
+    // Test it.
+    NinjaTarget target =
+        createParser(
+                "build $output : command $input $dir/abcde\n"
+                    + "  dir = def$input\n  empty = '$dir'")
+            .parseNinjaTarget(scope, 5);
+    assertThat(target.getRuleName()).isEqualTo("command");
+    assertThat(target.getOutputs()).containsExactly(PathFragment.create("out123"));
+    assertThat(target.getUsualInputs())
+        .containsExactly(PathFragment.create("in123"), PathFragment.create("defin123/abcde"));
+    assertThat(target.getVariables())
+        .containsExactlyEntriesIn(ImmutableSortedMap.of("dir", "defin123", "empty", "''"));
+  }
+
+  @Test
+  public void testPseudoCyclesOfVariables() {
+    NinjaScope scope = new NinjaScope();
+    scope.addVariable(
+        "output", 1, NinjaVariableValue.builder().addText("'out'").addVariable("input").build());
+    scope.addVariable(
+        "input", 2, NinjaVariableValue.builder().addText("'in'").addVariable("output").build());
+    scope.expandVariables();
+    assertThat(scope.findExpandedVariable(3, "input")).isEqualTo("'in''out'");
+    assertThat(scope.findExpandedVariable(3, "output")).isEqualTo("'out'");
+  }
+
+  @Test
+  public void testNinjaTargetsPathWithEscapedSpace() throws GenericParsingException {
+    NinjaTarget target = parseNinjaTarget("build output : command input$ with$ space other");
+    assertThat(target.getRuleName()).isEqualTo("command");
+    assertThat(target.getOutputs()).containsExactly(PathFragment.create("output"));
+    assertThat(target.getUsualInputs())
+        .containsExactly(PathFragment.create("input with space"), PathFragment.create("other"));
+  }
+
+  private static void testNinjaTargetParsingError(String text, String error) {
+    GenericParsingException exception =
+        assertThrows(GenericParsingException.class, () -> parseNinjaTarget(text));
+    assertThat(exception).hasMessageThat().isEqualTo(error);
+  }
+
+  private static NinjaTarget parseNinjaTarget(String text) throws GenericParsingException {
+    NinjaScope fileScope = new NinjaScope();
+    fileScope.expandVariables();
+    return createParser(text).parseNinjaTarget(fileScope, 0);
+  }
+
   private static void doTestNinjaRuleParsingException(String text, String message) {
     GenericParsingException exception =
         assertThrows(GenericParsingException.class, () -> createParser(text).parseNinjaRule());
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaScopeTest.java b/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaScopeTest.java
index fc49b55..a73dd42 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaScopeTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaScopeTest.java
@@ -292,6 +292,6 @@
   private static NinjaVariableValue parseValue(String text) throws GenericParsingException {
     ByteBuffer bb = ByteBuffer.wrap(text.getBytes(StandardCharsets.ISO_8859_1));
     NinjaLexer lexer = new NinjaLexer(new ByteBufferFragment(bb, 0, bb.limit()));
-    return new NinjaParser(lexer).parseVariableValue(true, "test");
+    return new NinjaParser(lexer).parseVariableValue("test");
   }
 }