Implement `gn edit` command, and implement a simple subcommand "set"

`gn edit` is a command heavily inspired by buildozer
(https://github.com/bazelbuild/buildtools/blob/main/buildozer/README.md).
It allows programattic editing of BUILD.gn files.

It is a soft requirement for the rollout of strict deps, and has three
primary use cases:
* Make AI able to edit build files more effectively
* Make large-scale rollouts easier to perform.
* Put the infrastructure in place for `gn check --fix` / `gn suggest --apply`

Bug: 528225104
Change-Id: Ic0a83aec04a4036aa6ff7fdcf593bc716a6a6964
Reviewed-on: https://gn-review.googlesource.com/c/gn/+/25281
Reviewed-by: Takuto Ikuta <tikuta@google.com>
Commit-Queue: Matt Stark <msta@google.com>
diff --git a/build/gen.py b/build/gen.py
index 1415058..5cb1015 100755
--- a/build/gen.py
+++ b/build/gen.py
@@ -747,6 +747,7 @@
               'src/gn/analyzer.cc',
               'src/gn/args.cc',
               'src/gn/binary_target_generator.cc',
+              'src/gn/build_file_editor.cc',
               'src/gn/build_settings.cc',
               'src/gn/builder.cc',
               'src/gn/builder_record.cc',
@@ -784,6 +785,8 @@
               'src/gn/deps_iterator.cc',
               'src/gn/desc_builder.cc',
               'src/gn/eclipse_writer.cc',
+              'src/gn/edit_subcommands.cc',
+              'src/gn/edit_command.cc',
               'src/gn/err.cc',
               'src/gn/escape.cc',
               'src/gn/exec_process.cc',
@@ -937,6 +940,7 @@
         'src/gn/config_unittest.cc',
         'src/gn/config_values_extractors_unittest.cc',
         'src/gn/desc_builder_unittest.cc',
+        'src/gn/edit_command_unittest.cc',
         'src/gn/escape_unittest.cc',
         'src/gn/exec_process_unittest.cc',
         'src/gn/filesystem_utils_unittest.cc',
diff --git a/docs/reference.md b/docs/reference.md
index b726a19..8053e94 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -11,6 +11,7 @@
     *   [clean: Cleans the output directory.](#cmd_clean)
     *   [clean_stale: Cleans the stale output files from the output directory.](#cmd_clean_stale)
     *   [desc: Show lots of insightful information about a target or config.](#cmd_desc)
+    *   [edit: Edit BUILD.gn files from the command line.](#cmd_edit)
     *   [format: Format .gn files.](#cmd_format)
     *   [gen: Generate ninja files.](#cmd_gen)
     *   [help: Does what you think.](#cmd_help)
@@ -713,6 +714,34 @@
       Shows defines set for the //base:base target, annotated by where
       each one was set from.
 ```
+### <a name="cmd_edit"></a>**gn edit &lt;command&gt; &lt;labels/patterns...&gt;**&nbsp;[Back to Top](#gn-reference)
+
+```
+  Executes a command to modify a set of targets
+
+  Note: Because GN is an imperative language, it's not always entirely
+  clear what the "correct" thing is to do.
+
+  In cases of ambiguity (eg. conditionals), `gn edit` will leave notes
+  in your build files instructing you what to do.
+```
+
+#### **Commands**:
+```
+  set <attribute>[:list] <value(s)>
+      Sets or overwrites the target's <attribute> to <value(s)>.
+      If multiple values are provided, or if the ":list" suffix is
+      appended to the attribute, <value(s)> is interpreted as a list.
+```
+
+#### **Examples**:
+```
+  gn edit "set testonly true" //src/tools:*
+      Sets 'testonly' to 'true' for all targets in
+      `//src/tools/BUILD.gn`.
+  gn edit "set srcs:list foo.cc foo.h" //:foo
+      Sets 'srcs' to '[ "foo.cc", "foo.h" ]' for //:foo.
+```
 ### <a name="cmd_format"></a>**gn format [\--dump-tree] [\--format-width=WIDTH] (\--stdin | &lt;list of build_files...&gt;)**&nbsp;[Back to Top](#gn-reference)
 
 ```
diff --git a/src/gn/build_file_editor.cc b/src/gn/build_file_editor.cc
new file mode 100644
index 0000000..01a346d
--- /dev/null
+++ b/src/gn/build_file_editor.cc
@@ -0,0 +1,360 @@
+// Copyright 2026 The GN Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "gn/build_file_editor.h"
+
+#include <algorithm>
+
+#include "base/files/file_enumerator.h"
+#include "base/files/file_util.h"
+#include "gn/build_settings.h"
+#include "gn/command_format.h"
+#include "gn/edit_subcommands.h"
+#include "gn/filesystem_utils.h"
+#include "gn/input_file.h"
+#include "gn/label.h"
+#include "gn/loader.h"
+#include "gn/parse_tree.h"
+#include "gn/parser.h"
+#include "gn/scope.h"
+#include "gn/string_atom.h"
+#include "gn/tokenizer.h"
+#include "gn/value.h"
+
+namespace {
+
+std::optional<std::string> AsStringLiteral(const ParseNode* node) {
+  auto* literal = node->AsLiteral();
+  if (!literal || literal->value().type() != Token::STRING) {
+    return std::nullopt;
+  }
+  Scope scope(static_cast<const Settings*>(nullptr));
+  Err err;
+  Value v = literal->Execute(&scope, &err);
+  // Because we provide an empty scope, "${b}" will result in an error.
+  if (err.has_error() || v.type() != Value::STRING) {
+    return std::nullopt;
+  }
+  return std::move(v.string_value());
+}
+
+// Resolves a single LabelPattern to matching SourceFiles.
+Result<std::vector<SourceFile>> ResolvePatternToFiles(
+    const BuildSettings* build_settings,
+    const Loader* loader,
+    const LabelPattern& pattern) {
+  std::vector<SourceFile> matched_files;
+  auto add_dir = [&](const SourceDir& dir) {
+    auto build_file = loader->BuildFileForLabel(Label(dir, "dummy"));
+    if (base::PathExists(build_settings->GetFullPath(build_file))) {
+      matched_files.push_back(build_file);
+    }
+  };
+
+  add_dir(pattern.dir());
+
+  if (pattern.type() == LabelPattern::MATCH ||
+      pattern.type() == LabelPattern::DIRECTORY) {
+    if (matched_files.empty()) {
+      return Err(
+          Location(),
+          "Build file does not exist: " +
+              loader->BuildFileForLabel(Label(pattern.dir(), "dummy")).value());
+    }
+    return matched_files;
+  }
+
+  base::FilePath disk_path = build_settings->GetFullPath(pattern.dir());
+  if (!base::DirectoryExists(disk_path)) {
+    return Err(Location(),
+               "Directory does not exist: " + pattern.dir().value());
+  }
+
+  base::FileEnumerator traverser(disk_path, /*recursive=*/true,
+                                 base::FileEnumerator::DIRECTORIES);
+  for (base::FilePath current = traverser.Next(); !current.empty();
+       current = traverser.Next()) {
+    base::FilePath relative;
+    if (build_settings->root_path().AppendRelativePath(current, &relative)) {
+      std::string source_path = "//" + FilePathToUTF8(relative) + "/";
+      NormalizePath(&source_path);
+      add_dir(SourceDir(source_path));
+    }
+  }
+
+  return matched_files;
+}
+
+}  // namespace
+
+bool TreeNode::is_conditional() const {
+  for (auto it = stack_.rbegin(); it != stack_.rend(); ++it) {
+    if ((*it)->AsCondition()) {
+      return true;
+    }
+    // Stop at the target boundary.
+    if ((*it)->AsFunctionCall()) {
+      break;
+    }
+  }
+  return false;
+}
+
+bool TreeNode::is_modification() const {
+  if (const auto* op = node()->AsBinaryOp()) {
+    return op->op().type() == Token::PLUS_EQUALS ||
+           op->op().type() == Token::MINUS_EQUALS;
+  }
+  return false;
+}
+
+void TreeNode::add_todo(EditState& state, const EditTarget& target) const {
+  const std::vector<std::string> lines = {
+      "# TODO(gn edit: " + state.context + "):",
+      "# This would normally be deleted but is conditional.",
+      "# Manual intervention is required to decide whether it should "
+      "actually be deleted.",
+  };
+  for (const auto& line : lines) {
+    StringAtom atom(line);
+    Token comment_token(node()->GetRange().begin(), Token::LINE_COMMENT,
+                        atom.str());
+    node()->comments_mutable()->append_before(std::move(comment_token));
+  }
+  state.needs_manual_review.insert(target.label);
+}
+
+void TreeNode::RemoveSelf(EditState& state, const EditTarget& target) const {
+  if (is_conditional()) {
+    add_todo(state, target);
+  } else {
+    RemoveSelf();
+  }
+}
+
+void TreeNode::RemoveSelf() const {
+  DCHECK(parent());
+  if (auto* block = parent()->AsBlockMut()) {
+    auto& stmts = block->statements();
+    for (auto it = stmts.begin(); it != stmts.end(); ++it) {
+      if (it->get() == node()) {
+        stmts.erase(it);
+        return;
+      }
+    }
+  } else if (auto* list = parent()->AsListMut()) {
+    auto& items = list->contents();
+    for (auto it = items.begin(); it != items.end(); ++it) {
+      if (it->get() == node()) {
+        items.erase(it);
+        return;
+      }
+    }
+  } else {
+    CHECK(false) << "Unsupported type to remove from";
+  }
+  CHECK(false) << "child node not found in parent's children";
+}
+
+LabelMatcher::LabelMatcher(SourceDir source_dir,
+                           const std::vector<LabelPattern>& patterns)
+    : source_dir_(std::move(source_dir)), globbed_(false) {
+  for (const auto& pattern : patterns) {
+    if (pattern.type() == LabelPattern::RECURSIVE_DIRECTORY &&
+        source_dir_.value().starts_with(pattern.dir().value())) {
+      globbed_ = true;
+    } else if (pattern.type() == LabelPattern::DIRECTORY &&
+               source_dir_ == pattern.dir()) {
+      globbed_ = true;
+    } else if (pattern.type() == LabelPattern::MATCH &&
+               pattern.dir() == source_dir_) {
+      used_[pattern.name()] = false;
+    }
+  }
+}
+
+LabelMatcher::MatchType LabelMatcher::matches(const std::string& name) {
+  if (auto it = used_.find(name); it != used_.end()) {
+    it->second = true;  // Mark as used.
+    return EXACT;
+  }
+  return globbed_ ? GLOB : NONE;
+}
+
+Err LabelMatcher::done() const {
+  std::vector<std::string> unused;
+  for (const auto& [name, used] : used_) {
+    if (!used) {
+      unused.push_back(name);
+    }
+  }
+  if (!unused.empty()) {
+    std::sort(unused.begin(), unused.end());
+    std::string msg = "Target(s) not found: ";
+    for (size_t i = 0; i < unused.size(); ++i) {
+      if (i > 0)
+        msg += ", ";
+      msg += Label(source_dir_, unused[i]).GetUserVisibleName(false);
+    }
+    return Err(Location(), msg);
+  }
+  return Ok();
+}
+
+std::vector<TreeNode> EditTarget::assignments(std::string_view attr) const {
+  return FindStatement<TreeNode>(
+      block, [attr](TreeNode& node_ref) -> std::optional<TreeNode> {
+        if (const auto* op = node_ref->AsBinaryOp()) {
+          if (op->op().type() == Token::EQUAL ||
+              op->op().type() == Token::PLUS_EQUALS ||
+              op->op().type() == Token::MINUS_EQUALS) {
+            if (const auto* left = op->left()->AsIdentifier()) {
+              if (left->value().value() == attr) {
+                return node_ref;
+              }
+            }
+          }
+        }
+        return std::nullopt;
+      });
+}
+
+void EditTarget::add_warning(EditState& state, std::string_view message) const {
+  std::string full_message = "Target \"" + label.GetUserVisibleName(false) +
+                             "\" " + std::string(message);
+  state.warnings.push_back(Err(node.node()->GetRange().begin(), full_message));
+}
+
+Result<BuildFile> BuildFile::Create(const BuildSettings* build_settings,
+                                    const SourceFile& source_file,
+                                    const std::vector<LabelPattern>& patterns) {
+  auto input_file = std::make_unique<InputFile>(source_file);
+  base::FilePath full_path = build_settings->GetFullPath(source_file);
+  if (!input_file->Load(full_path)) {
+    return Err(Location(), "Could not load file: " + source_file.value());
+  }
+
+  Err err;
+  std::vector<Token> tokens = Tokenizer::Tokenize(input_file.get(), &err);
+  RETURN_IF_ERROR(err);
+
+  std::unique_ptr<ParseNode> tree_root = Parser::Parse(tokens, &err);
+  RETURN_IF_ERROR(err);
+
+  LabelMatcher label_matcher(source_file.GetDir(), patterns);
+  return BuildFile(build_settings, source_file, std::move(input_file),
+                   std::move(tree_root), std::move(label_matcher));
+}
+
+Location BuildFile::location() const {
+  return Location(input_file_.get(), 1, 1);
+}
+
+std::vector<EditTarget> BuildFile::targets() {
+  return FindStatement<EditTarget>(
+      tree_root_.get(),
+      [this](TreeNode& node_ref) -> std::optional<EditTarget> {
+        if (auto* func = node_ref->AsFunctionCallMut()) {
+          if (func->block() && func->args() &&
+              func->args()->contents().size() == 1) {
+            if (auto name =
+                    AsStringLiteral(func->args()->contents()[0].get())) {
+              auto match_type = label_matcher_.matches(*name);
+              if (match_type != LabelMatcher::NONE) {
+                return EditTarget{
+                    .is_explicit = match_type == LabelMatcher::EXACT,
+                    .label = Label(source_file_.GetDir(), *name),
+                    .node = node_ref,
+                    .block = func->block(),
+                };
+              }
+            }
+          }
+        }
+        return std::nullopt;
+      });
+}
+
+std::unique_ptr<ParseNode> BuildFile::to_node(const Value& value) {
+  auto file = std::make_unique<InputFile>(SourceFile("//dummy"));
+  file->SetContents(value.ToString(true));
+
+  Err err;
+  std::vector<Token> tokens = Tokenizer::Tokenize(file.get(), &err);
+  for (auto& token : tokens) {
+    token.set_location(this->location());
+  }
+  auto parsed = Parser::ParseExpression(tokens, &err);
+  extra_files_.push_back(std::move(file));
+  // value.ToString() must return something parsable as input to GN.
+  DCHECK(!err.has_error());
+  return parsed;
+}
+
+std::unique_ptr<IdentifierNode> BuildFile::create_identifier(
+    std::string_view value) {
+  StringAtom atom(value);
+  return std::make_unique<IdentifierNode>(
+      Token(location(), Token::IDENTIFIER, atom.str()));
+}
+
+std::unique_ptr<BinaryOpNode> BuildFile::create_assignment(
+    std::string_view name,
+    std::unique_ptr<ParseNode> value) {
+  auto left = create_identifier(name);
+
+  auto assign = std::make_unique<BinaryOpNode>();
+  assign->set_op(Token(location(), Token::EQUAL, "="));
+  assign->set_left(std::move(left));
+  assign->set_right(std::move(value));
+
+  return assign;
+}
+
+Result<bool> BuildFile::Write() {
+  ASSIGN_OR_RETURN(std::string formatted, commands::FormatNodeToString(root()));
+  if (input_file_->contents() == formatted) {
+    return false;
+  }
+  base::FilePath file_path = build_settings_->GetFullPath(source_file());
+  if (base::WriteFile(file_path, formatted.data(),
+                      static_cast<int>(formatted.size())) == -1) {
+    return Err(Location(),
+               "Failed to write to file: " + FilePathToUTF8(file_path));
+  }
+  return true;
+}
+
+BuildFile::BuildFile(const BuildSettings* build_settings,
+                     SourceFile source_file,
+                     std::unique_ptr<InputFile> input_file,
+                     std::unique_ptr<ParseNode> tree_root,
+                     LabelMatcher label_matcher)
+    : build_settings_(build_settings),
+      source_file_(std::move(source_file)),
+      input_file_(std::move(input_file)),
+      tree_root_(std::move(tree_root)),
+      label_matcher_(std::move(label_matcher)) {}
+
+Result<std::vector<BuildFile>> ResolvePatternsToBuildFiles(
+    const BuildSettings* build_settings,
+    const Loader* loader,
+    const std::vector<LabelPattern>& patterns) {
+  std::set<SourceFile> seen;
+  std::vector<BuildFile> result;
+
+  for (const LabelPattern& pattern : patterns) {
+    ASSIGN_OR_RETURN(auto files,
+                     ResolvePatternToFiles(build_settings, loader, pattern));
+
+    for (const SourceFile& file : files) {
+      if (seen.insert(file).second) {
+        ASSIGN_OR_RETURN(auto parsed,
+                         BuildFile::Create(build_settings, file, patterns));
+        result.push_back(std::move(parsed));
+      }
+    }
+  }
+  return result;
+}
diff --git a/src/gn/build_file_editor.h b/src/gn/build_file_editor.h
new file mode 100644
index 0000000..a003816
--- /dev/null
+++ b/src/gn/build_file_editor.h
@@ -0,0 +1,217 @@
+// Copyright 2026 The GN Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef TOOLS_GN_BUILD_FILE_EDITOR_H_
+#define TOOLS_GN_BUILD_FILE_EDITOR_H_
+
+#include <functional>
+#include <memory>
+#include <optional>
+#include <unordered_map>
+#include <vector>
+
+#include "gn/err.h"
+#include "gn/input_file.h"
+#include "gn/label.h"
+#include "gn/label_pattern.h"
+#include "gn/parse_tree.h"
+#include "gn/source_file.h"
+
+class BuildSettings;
+class Loader;
+
+struct EditState;
+struct EditTarget;
+
+// A TreeNode represents a node in a tree.
+// It fundamentally represents a ParseNode, but differs from one as it
+// understands where it exists in the tree.
+class TreeNode {
+ public:
+  explicit TreeNode(std::vector<ParseNode*> stack) : stack_(std::move(stack)) {
+    DCHECK(!stack_.empty());
+  }
+
+  ParseNode* node() const { return stack_.back(); }
+  ParseNode* parent() const {
+    return stack_.size() > 1 ? stack_[stack_.size() - 2] : nullptr;
+  }
+
+  // Returns whether the node is conditional in a target.
+  // Note that if the target itself is conditional, this will return false.
+  bool is_conditional() const;
+
+  // Returns whether the node is a "+=" or "-=" operation.
+  bool is_modification() const;
+
+  // Adds a todo comment to the build file to show the user where manual
+  // intervention is required.
+  void add_todo(EditState& state, const EditTarget& target) const;
+
+  // Removes self from the tree, or adds a TODO suggesting that it should
+  // probably be removed.
+  void RemoveSelf(EditState& state, const EditTarget& target) const;
+
+  ParseNode* operator->() const { return stack_.back(); }
+
+ private:
+  // Low-level deletion from parent block or list.
+  void RemoveSelf() const;
+
+  std::vector<ParseNode*> stack_;
+};
+
+template <typename T>
+void FindStatementRecursive(
+    ParseNode* node,
+    std::vector<ParseNode*>& stack,
+    const std::function<std::optional<T>(TreeNode&)>& transform,
+    std::vector<T>* results) {
+  if (!node)
+    return;
+
+  stack.push_back(node);
+
+  TreeNode node_ref(stack);
+  if (auto mapped = transform(node_ref)) {
+    results->push_back(std::move(*mapped));
+  }
+
+  if (auto* block = node->AsBlock()) {
+    for (const auto& stmt : block->statements()) {
+      FindStatementRecursive(stmt.get(), stack, transform, results);
+    }
+  } else if (auto* condition = node->AsCondition()) {
+    FindStatementRecursive(const_cast<BlockNode*>(condition->if_true()), stack,
+                           transform, results);
+    if (condition->if_false()) {
+      FindStatementRecursive(const_cast<ParseNode*>(condition->if_false()),
+                             stack, transform, results);
+    }
+  } else if (auto* func = node->AsFunctionCall(); func && func->block()) {
+    FindStatementRecursive(const_cast<BlockNode*>(func->block()), stack,
+                           transform, results);
+  }
+
+  stack.pop_back();
+}
+
+// Returns a vector of nodes matching a condition.
+// May also apply a transformation to add useful metadata to them.
+template <typename T>
+std::vector<T> FindStatement(
+    ParseNode* root,
+    const std::function<std::optional<T>(TreeNode&)>& transform) {
+  std::vector<T> results;
+  std::vector<ParseNode*> stack;
+  FindStatementRecursive<T>(root, stack, transform, &results);
+  return results;
+}
+
+// Represents a set of patterns within a build file.
+class LabelMatcher {
+ public:
+  LabelMatcher(SourceDir source_dir, const std::vector<LabelPattern>& patterns);
+
+  enum MatchType {
+    // Target does not match any pattern in this build file.
+    NONE,
+    // Target was explicitly named (e.g. "//foo:bar"). Unmatched explicit
+    // targets will trigger an error when done() is called.
+    EXACT,
+    // Target matched a wildcard pattern (e.g. "//foo:*" or "//foo/*").
+    GLOB,
+  };
+
+  // Checks whether a label was a match for a given pattern.
+  MatchType matches(const std::string& name);
+
+  // Call this when done editing a build file.
+  // Any explicitly requested targets that were unused will trigger an error.
+  Err done() const;
+
+ private:
+  SourceDir source_dir_;
+  bool globbed_ = false;
+  std::unordered_map<std::string, bool> used_;
+};
+
+// Represents a build target to be edited.
+struct EditTarget {
+  // Calculates all =, +=, and -= of a given attribute.
+  std::vector<TreeNode> assignments(std::string_view attr) const;
+
+  // Emits a warning to the user.
+  void add_warning(EditState& state, std::string_view message) const;
+
+  // True if the target was explicitly requested to be edited.
+  // This is relevant, because if the user requests something like
+  // "remove deps //dep" //:*, then we should not print warnings
+  // if not all targets depend on //dep.
+  // On the other hand, if the user says "remove deps //dep" //:foo,
+  // and we can't find a dep on //dep, we should warn them about it.
+  bool is_explicit;
+  Label label;
+  TreeNode node;
+  BlockNode* block;
+};
+
+// Represents a build file to be edited.
+class BuildFile {
+ public:
+  static Result<BuildFile> Create(const BuildSettings* build_settings,
+                                  const SourceFile& source_file,
+                                  const std::vector<LabelPattern>& patterns);
+
+  const SourceFile& source_file() const { return source_file_; }
+  ParseNode* root() const { return tree_root_.get(); }
+  LabelMatcher& label_matcher() { return label_matcher_; }
+
+  // Returns a generic location at the start of the file.
+  // This is relevant because generated nodes won't have location information.
+  Location location() const;
+
+  // Returns all targets matching the patterns.
+  std::vector<EditTarget> targets();
+
+  // Creates a node to insert into the graph.
+  std::unique_ptr<ParseNode> to_node(const Value& value);
+
+  // Creates a node for an identifier.
+  std::unique_ptr<IdentifierNode> create_identifier(std::string_view value);
+  // Creates a node for `a = b`
+  std::unique_ptr<BinaryOpNode> create_assignment(
+      std::string_view name,
+      std::unique_ptr<ParseNode> value);
+
+  // Serializes the AST to the build file if it has changed.
+  // Returns Ok(true) if the file was written, Ok(false) if it was unchanged.
+  Result<bool> Write();
+
+ private:
+  BuildFile(const BuildSettings* build_settings,
+            SourceFile source_file,
+            std::unique_ptr<InputFile> input_file,
+            std::unique_ptr<ParseNode> tree_root,
+            LabelMatcher label_matcher);
+
+  const BuildSettings* build_settings_;
+  SourceFile source_file_;
+  std::unique_ptr<InputFile> input_file_;
+  std::unique_ptr<ParseNode> tree_root_;
+  // Our custom ParseNodes generated by to_node contain string views.
+  // We store objects containing the underlying strings here to ensure they
+  // live long enough.
+  std::vector<std::unique_ptr<InputFile>> extra_files_;
+  LabelMatcher label_matcher_;
+};
+
+// Resolves a list of LabelPatterns into the union of the build files they
+// cover.
+Result<std::vector<BuildFile>> ResolvePatternsToBuildFiles(
+    const BuildSettings* build_settings,
+    const Loader* loader,
+    const std::vector<LabelPattern>& patterns);
+
+#endif  // TOOLS_GN_BUILD_FILE_EDITOR_H_
diff --git a/src/gn/command_format.cc b/src/gn/command_format.cc
index b49c480..e00b271 100644
--- a/src/gn/command_format.cc
+++ b/src/gn/command_format.cc
@@ -1468,6 +1468,13 @@
   return true;
 }
 
+Result<std::string> FormatNodeToString(const ParseNode* root) {
+  std::string output;
+  std::string dump;
+  DoFormat(root, TreeDumpMode::kInactive, kDefaultFormatWidth, &output, &dump);
+  return output;
+}
+
 int RunFormat(const std::vector<std::string>& args) {
 #if defined(OS_WIN)
   // Set to binary mode to prevent converting newlines to \r\n.
diff --git a/src/gn/command_format.h b/src/gn/command_format.h
index 7854d9e..59dbd0e 100644
--- a/src/gn/command_format.h
+++ b/src/gn/command_format.h
@@ -7,6 +7,9 @@
 
 #include <string>
 
+#include "gn/err.h"
+
+class ParseNode;
 class Setup;
 class SourceFile;
 
@@ -37,6 +40,8 @@
                           std::string* output,
                           std::string* dump_output);
 
+Result<std::string> FormatNodeToString(const ParseNode* root);
+
 }  // namespace commands
 
 #endif  // TOOLS_GN_COMAND_FORMAT_H_
diff --git a/src/gn/commands.cc b/src/gn/commands.cc
index 582ba01..1c2c16b 100644
--- a/src/gn/commands.cc
+++ b/src/gn/commands.cc
@@ -407,6 +407,7 @@
     INSERT_COMMAND(Desc)
     INSERT_COMMAND(Gen)
     INSERT_COMMAND(Format)
+    INSERT_COMMAND(Edit)
     INSERT_COMMAND(Help)
     INSERT_COMMAND(Meta)
     INSERT_COMMAND(Ls)
diff --git a/src/gn/commands.h b/src/gn/commands.h
index 812d342..30a607f 100644
--- a/src/gn/commands.h
+++ b/src/gn/commands.h
@@ -70,6 +70,11 @@
 extern const char kFormat_Help[];
 int RunFormat(const std::vector<std::string>& args);
 
+extern const char kEdit[];
+extern const char kEdit_HelpShort[];
+extern const char kEdit_Help[];
+int RunEdit(const std::vector<std::string>& args);
+
 extern const char kHelp[];
 extern const char kHelp_HelpShort[];
 extern const char kHelp_Help[];
diff --git a/src/gn/edit_command.cc b/src/gn/edit_command.cc
new file mode 100644
index 0000000..b8095ff
--- /dev/null
+++ b/src/gn/edit_command.cc
@@ -0,0 +1,146 @@
+// Copyright 2026 The GN Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "gn/edit_command.h"
+
+#include <iomanip>
+#include <sstream>
+#include <utility>
+#include <vector>
+
+#include "gn/build_file_editor.h"
+#include "gn/commands.h"
+#include "gn/edit_subcommands.h"
+#include "gn/filesystem_utils.h"
+#include "gn/setup.h"
+#include "gn/source_file.h"
+#include "gn/standard_out.h"
+#include "gn/value.h"
+
+namespace commands {
+
+const char kEdit[] = "edit";
+const char kEdit_HelpShort[] =
+    "edit: Edit BUILD.gn files from the command line.";
+const char kEdit_Help[] =
+    "gn edit <command> <labels/patterns...>\n"
+    "\n"
+    "  Executes a command to modify a set of targets\n"
+    "\n"
+    "  Note: Because GN is an imperative language, it's not always entirely\n"
+    "  clear what the \"correct\" thing is to do.\n"
+    "\n"
+    "  In cases of ambiguity (eg. conditionals), `gn edit` will leave notes\n"
+    "  in your build files instructing you what to do.\n"
+    "\n"
+    "Commands:\n"
+    "  set <attribute>[:list] <value(s)>\n"
+    "      Sets or overwrites the target's <attribute> to <value(s)>.\n"
+    "      If multiple values are provided, or if the \":list\" suffix is\n"
+    "      appended to the attribute, <value(s)> is interpreted as a list.\n"
+    "\n"
+    "Examples:\n"
+    "  gn edit \"set testonly true\" //src/tools:*\n"
+    "      Sets 'testonly' to 'true' for all targets in\n"
+    "      `//src/tools/BUILD.gn`.\n"
+    "  gn edit \"set srcs:list foo.cc foo.h\" //:foo\n"
+    "      Sets 'srcs' to '[ \"foo.cc\", \"foo.h\" ]' for //:foo.\n";
+
+Result<std::pair<std::vector<SourceFile>, EditState>> RunEditImpl(
+    const std::vector<std::string>& args,
+    Setup& setup) {
+  if (args.size() < 2) {
+    return Err(Location(), "Insufficient arguments.",
+               "Usage: gn edit <command> <labels...>\n"
+               "Example: gn edit \"set testonly true\" //foo:*");
+  }
+
+  // We use std::quoted to tokenize the command.
+  // eg. set foo "bar baz" -> ["set", "foo", "bar baz"].
+  std::stringstream ss(args[0]);
+  std::vector<std::string> command_tokens;
+  std::string token;
+  while (ss >> std::ws && !ss.eof()) {
+    if (!(ss >> std::quoted(token))) {
+      return Err(Location(), "Unclosed quote in command string.");
+    }
+    command_tokens.push_back(std::move(token));
+  }
+  if (command_tokens.empty()) {
+    return Err(Location(), "Empty command string.");
+  }
+
+  ASSIGN_OR_RETURN(EditCommand command,
+                   ParseCommand(std::move(command_tokens)));
+  const SourceDir current_dir =
+      SourceDirForCurrentDirectory(setup.build_settings().root_path());
+  const std::string source_root = setup.build_settings().root_path_utf8();
+
+  std::vector<LabelPattern> patterns;
+  for (size_t i = 1; i < args.size(); ++i) {
+    Value val(nullptr, args[i]);
+    Err err;
+    LabelPattern pattern =
+        LabelPattern::GetPattern(current_dir, source_root, val, &err);
+    if (err.has_error()) {
+      return err;
+    }
+    patterns.push_back(std::move(pattern));
+  }
+
+  ASSIGN_OR_RETURN(std::vector<BuildFile> build_files,
+                   ::ResolvePatternsToBuildFiles(&setup.build_settings(),
+                                                 setup.loader(), patterns));
+
+  EditState state(args[0]);
+  for (auto& build_file : build_files) {
+    RETURN_IF_ERROR(command(build_file, state));
+    RETURN_IF_ERROR(build_file.label_matcher().done());
+  }
+
+  std::vector<SourceFile> modified_files;
+  for (auto& build_file : build_files) {
+    ASSIGN_OR_RETURN(bool wrote, build_file.Write());
+    if (wrote) {
+      modified_files.push_back(build_file.source_file());
+    }
+  }
+
+  return std::make_pair(std::move(modified_files), std::move(state));
+}
+
+int RunEdit(const std::vector<std::string>& args) {
+  Setup setup;
+  if (!setup.DoSetupForEditing()) {
+    return 1;
+  }
+  auto result = RunEditImpl(args, setup);
+  if (result.has_error()) {
+    result.error().PrintToStdout();
+    return 1;
+  }
+  for (const auto& file : result->first) {
+    OutputString("Wrote '" + file.value() + "'.\n");
+  }
+  for (const auto& warning : result->second.warnings) {
+    warning.PrintNonfatalToStdout();
+  }
+  const auto& review_needed = result->second.needs_manual_review;
+  if (!review_needed.empty()) {
+    OutputString("\nThe following targets need manual review:\n",
+                 DECORATION_YELLOW);
+    for (const Label& label : review_needed) {
+      OutputString("* ");
+      OutputString(label.GetUserVisibleName(false) + "\n", DECORATION_GREEN);
+    }
+
+    OutputString(
+        "\nWhere manual review is required, comments have been added to the "
+        "build file of the form:\n'# TODO(gn edit: <command>): ...'\n",
+        DECORATION_DIM);
+  }
+  return 0;
+}
+
+}  // namespace commands
diff --git a/src/gn/edit_command.h b/src/gn/edit_command.h
new file mode 100644
index 0000000..c3414d3
--- /dev/null
+++ b/src/gn/edit_command.h
@@ -0,0 +1,25 @@
+// Copyright 2026 The GN Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef TOOLS_GN_EDIT_COMMAND_H_
+#define TOOLS_GN_EDIT_COMMAND_H_
+
+#include <string>
+#include <vector>
+
+#include "gn/edit_subcommands.h"
+#include "gn/err.h"
+#include "gn/source_file.h"
+
+class Setup;
+namespace commands {
+
+// Runs an edit command, and returns a list of files that were modified.
+Result<std::pair<std::vector<SourceFile>, EditState>> RunEditImpl(
+    const std::vector<std::string>& args,
+    Setup& setup);
+
+}  // namespace commands
+
+#endif  // TOOLS_GN_EDIT_COMMAND_H_
diff --git a/src/gn/edit_command_unittest.cc b/src/gn/edit_command_unittest.cc
new file mode 100644
index 0000000..1961e56
--- /dev/null
+++ b/src/gn/edit_command_unittest.cc
@@ -0,0 +1,234 @@
+// Copyright 2026 The GN Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "gn/edit_command.h"
+
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "base/files/file_util.h"
+#include "base/files/scoped_temp_dir.h"
+#include "gn/err.h"
+#include "gn/filesystem_utils.h"
+#include "gn/setup.h"
+#include "gn/test_with_scheduler.h"
+#include "util/test/test.h"
+
+namespace commands {
+namespace {
+struct Edited;
+std::string Pretty(const Edited& edited);
+
+struct Edited {
+  Edited(std::string_view contents, EditState edit_state = EditState())
+      : contents_(contents.starts_with('\n') ? contents.substr(1) : contents),
+        edit_state_(std::move(edit_state)) {}
+
+  bool operator==(const Edited& other) const {
+    return Pretty(*this) == Pretty(other);
+  }
+
+  std::string contents_;
+  EditState edit_state_;
+};
+
+std::string Pretty(const Edited& edited) {
+  std::string res = edited.contents_;
+  if (!edited.edit_state_.needs_manual_review.empty()) {
+    res += "\nNeeds manual review: " +
+           testing::Pretty(edited.edit_state_.needs_manual_review);
+  }
+  if (!edited.edit_state_.warnings.empty()) {
+    res += "\nWarnings: " + testing::Pretty(edited.edit_state_.warnings);
+  }
+  return res;
+}
+
+// Runs an edit command on matching the given target
+// patterns, and returns the formatted output file contents.
+Result<Edited> DoEdit(std::string command,
+                      std::vector<std::string> patterns,
+                      const std::string& before) {
+  base::ScopedTempDir temp_dir;
+  if (!temp_dir.CreateUniqueTempDir()) {
+    return Err(Location(), "Failed to create temp dir");
+  }
+  base::FilePath root_path = base::MakeAbsoluteFilePath(temp_dir.GetPath());
+
+  base::FilePath build_gn_path = root_path.AppendASCII("BUILD.gn");
+  if (!WriteFile(build_gn_path, before, nullptr)) {
+    return Err(Location(), "Failed to write BUILD.gn");
+  }
+  base::FilePath dot_gn_path = root_path.AppendASCII(".gn");
+  if (!WriteFile(dot_gn_path, "", nullptr)) {
+    return Err(Location(), "Failed to write .gn");
+  }
+
+  Setup setup;
+  setup.build_settings().SetRootPath(root_path);
+
+  std::vector<std::string> args;
+  args.push_back(std::move(command));
+  for (auto& p : patterns) {
+    args.push_back(std::move(p));
+  }
+
+  auto result = RunEditImpl(args, setup);
+  if (result.has_error()) {
+    return result.error();
+  }
+
+  std::string after;
+  if (!base::ReadFileToString(build_gn_path, &after)) {
+    return Err(Location(), "Failed to read BUILD.gn");
+  }
+  return Edited(after, std::move(result->second));
+}
+
+// Runs an edit command matching all targets in the root BUILD.gn ("//:*").
+Result<Edited> DoEdit(std::string command, const std::string& before) {
+  return DoEdit(std::move(command), {"//:*"}, before);
+}
+
+}  // namespace
+
+using EditCommandTest = TestWithScheduler;
+
+TEST_F(EditCommandTest, MultipleTargetsSubset) {
+  EXPECT_SUCCESS(DoEdit("set testonly true", {"//:foo"},
+                        R"(
+executable("foo") {
+  testonly = false
+}
+executable("bar") {
+  testonly = false
+}
+)"),
+                 Edited(R"(
+executable("foo") {
+  testonly = true
+}
+executable("bar") {
+  testonly = false
+}
+)"));
+}
+
+TEST_F(EditCommandTest, PatternNeverMatched) {
+  EXPECT_FAILURE(DoEdit("set testonly true", {"//:nonexistent"},
+                        R"(
+executable("foo") {
+}
+)"),
+                 "Target(s) not found: //:nonexistent");
+}
+
+TEST_F(EditCommandTest, SetSubcommand) {
+  // New bool attribute
+  EXPECT_SUCCESS(DoEdit("set testonly true",
+                        R"(
+executable("foo") {
+}
+)"),
+                 Edited(R"(
+executable("foo") {
+  testonly = true
+}
+)"));
+
+  // Replacing existing attribute
+  EXPECT_SUCCESS(DoEdit("set testonly false",
+                        R"(
+executable("foo") {
+  testonly = true
+}
+)"),
+                 Edited(R"(
+executable("foo") {
+  testonly = false
+}
+)"));
+
+  // String attribute
+  EXPECT_SUCCESS(DoEdit("set label \"//foo:bar\"",
+                        R"(
+executable("foo") {
+}
+)"),
+                 Edited(R"(
+executable("foo") {
+  label = "//foo:bar"
+}
+)"));
+
+  // Int attribute
+  EXPECT_SUCCESS(DoEdit("set assert_no_deps 42",
+                        R"(
+executable("foo") {
+}
+)"),
+                 Edited(R"(
+executable("foo") {
+  assert_no_deps = 42
+}
+)"));
+
+  // Multiple values setting a list (replaces first, deletes modification,
+  // adds review for conditional)
+  EXPECT_SUCCESS(DoEdit("set deps //foo //bar",
+                        R"(
+executable("foo") {
+  deps = [ "//bar" ]
+  deps += [ "//baz" ]
+  if (is_linux) {
+    deps += [ "//linux" ]
+  }
+}
+)"),
+                 Edited(
+                     R"(
+executable("foo") {
+  deps = [
+    "//bar",
+    "//foo",
+  ]
+
+  if (is_linux) {
+    # TODO(gn edit: set deps //foo //bar):
+    # This would normally be deleted but is conditional.
+    # Manual intervention is required to decide whether it should actually be deleted.
+    deps += [ "//linux" ]
+  }
+}
+)",
+                     EditState({Label(SourceDir("//"), "foo")})));
+
+  // Forced list attribute (appends new list, adds review for conditional)
+  EXPECT_SUCCESS(DoEdit("set deps:list //foo",
+                        R"(
+executable("foo") {
+  if (is_linux) {
+    deps = [ "//linux" ]
+    public_deps = [ "//linux" ]
+  }
+}
+)"),
+                 Edited(
+                     R"(
+executable("foo") {
+  if (is_linux) {
+    # TODO(gn edit: set deps:list //foo):
+    # This would normally be deleted but is conditional.
+    # Manual intervention is required to decide whether it should actually be deleted.
+    deps = [ "//linux" ]
+    public_deps = [ "//linux" ]
+  }
+  deps = [ "//foo" ]
+}
+)",
+                     EditState({Label(SourceDir("//"), "foo")})));
+}
+
+}  // namespace commands
diff --git a/src/gn/edit_subcommands.cc b/src/gn/edit_subcommands.cc
new file mode 100644
index 0000000..243cff5
--- /dev/null
+++ b/src/gn/edit_subcommands.cc
@@ -0,0 +1,130 @@
+// Copyright 2026 The GN Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "gn/edit_subcommands.h"
+
+#include "base/containers/span.h"
+#include "base/strings/string_number_conversions.h"
+#include "gn/build_file_editor.h"
+#include "gn/err.h"
+#include "gn/location.h"
+#include "gn/parse_tree.h"
+#include "gn/value.h"
+
+namespace {
+
+// Parses a single string argument into a primitive GN Value (bool, int, or
+// string).
+Result<Value> ParseValue(std::string_view val_string) {
+  if (val_string == "true") {
+    return Value(nullptr, true);
+  }
+  if (val_string == "false") {
+    return Value(nullptr, false);
+  }
+
+  int64_t result_int;
+  if (base::StringToInt64(val_string, &result_int)) {
+    return Value(nullptr, result_int);
+  }
+
+  return Value(nullptr, std::string(val_string));
+}
+
+// Parses multiple string arguments into a vector of GN Values.
+Result<std::vector<Value>> ParseValues(base::span<const std::string> values) {
+  std::vector<Value> list_elements;
+  list_elements.reserve(values.size());
+  for (const std::string& val_str : values) {
+    ASSIGN_OR_RETURN(Value val, ParseValue(val_str));
+    list_elements.push_back(std::move(val));
+  }
+  return list_elements;
+}
+
+const TreeNode* FirstAssignment(const std::vector<TreeNode>& assignments) {
+  for (const auto& assignment : assignments) {
+    if (!assignment.is_conditional() && !assignment.is_modification())
+      return &assignment;
+  }
+  return nullptr;
+}
+
+// Helper to create an EditCommand that loops over all matched targets in a
+// BuildFile.
+EditCommand EditTargetCommand(
+    std::function<Err(BuildFile&, const EditTarget&, EditState&)>
+        apply_to_target) {
+  return [apply_to_target = std::move(apply_to_target)](
+             BuildFile& build_file, EditState& state) -> Err {
+    for (const auto& target : build_file.targets()) {
+      RETURN_IF_ERROR(apply_to_target(build_file, target, state));
+    }
+    return Ok();
+  };
+}
+
+// Sets an attribute to a value.
+EditCommand SetCommand(std::string attribute, Value value) {
+  return EditTargetCommand([=](BuildFile& build_file, const EditTarget& target,
+                               EditState& state) -> Err {
+    auto assignments = target.assignments(attribute);
+    const auto* first = FirstAssignment(assignments);
+    for (const auto& assignment : assignments) {
+      if (&assignment != first) {
+        assignment.RemoveSelf(state, target);
+      }
+    }
+
+    if (first) {
+      (*first)->AsBinaryOpMut()->set_right(build_file.to_node(value));
+    } else {
+      target.block->append_statement(
+          build_file.create_assignment(attribute, build_file.to_node(value)));
+    }
+
+    return Ok();
+  });
+}
+
+}  // namespace
+
+Result<EditCommand> ParseCommand(std::vector<std::string> args) {
+  if (args.empty()) {
+    return Err(Location(), "Empty command.");
+  }
+
+  if (args[0] == "set") {
+    if (args.size() < 3) {
+      return Err(Location(),
+                 "Invalid set command: missing attribute or value.\n"
+                 "Usage: set <attribute> <value...>");
+    }
+
+    std::string_view attribute = args[1];
+    bool force_list = false;
+    constexpr std::string_view kListSuffix = ":list";
+    if (attribute.ends_with(kListSuffix)) {
+      attribute.remove_suffix(kListSuffix.size());
+      force_list = true;
+    }
+
+    auto value_args = base::make_span(args).subspan(2);
+    Value val;
+    if (value_args.size() > 1 || force_list) {
+      ASSIGN_OR_RETURN(std::vector<Value> list_elements,
+                       ParseValues(value_args));
+      val = Value(nullptr, std::move(list_elements));
+    } else {
+      ASSIGN_OR_RETURN(val, ParseValue(value_args[0]));
+    }
+
+    return SetCommand(std::string(attribute), std::move(val));
+  }
+
+  return Err(Location(),
+             "Unknown edit command: " + std::string(args[0]) +
+                 "\n"
+                 "See `gn help edit` for list of supported commands.");
+}
diff --git a/src/gn/edit_subcommands.h b/src/gn/edit_subcommands.h
new file mode 100644
index 0000000..720be9d
--- /dev/null
+++ b/src/gn/edit_subcommands.h
@@ -0,0 +1,45 @@
+// Copyright 2026 The GN Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef TOOLS_GN_EDIT_SUBCOMMANDS_H_
+#define TOOLS_GN_EDIT_SUBCOMMANDS_H_
+
+#include <functional>
+#include <set>
+#include <string>
+#include <vector>
+
+#include "gn/err.h"
+#include "gn/label.h"
+
+class BuildFile;
+
+struct EditState {
+  explicit EditState(std::string context) : context(std::move(context)) {}
+
+  EditState() = default;
+  EditState(std::set<Label> review, std::vector<Err> warn = {})
+      : needs_manual_review(std::move(review)), warnings(std::move(warn)) {}
+
+  // When something needs manual review, gn will output
+  // "# TODO(gn edit: <context>)"
+  std::string context;
+
+  // Each label in this list needs manual review. # TODO(gn edit) comments
+  // will have been added to the build file to give the user more precise
+  // instructions.
+  std::set<Label> needs_manual_review;
+  // Extra information the user should be wary of. For example, if the user
+  // runs: `gn edit "remove deps //bar" //foo`, but //bar was not a dependency
+  // of //foo.
+  std::vector<Err> warnings;
+};
+
+// EditCommand is a function that modifies a build file.
+using EditCommand = std::function<Err(BuildFile& build_file, EditState& state)>;
+
+// Parses a command such as ["set", "testonly", "true"] into an EditCommand.
+Result<EditCommand> ParseCommand(std::vector<std::string> args);
+
+#endif  // TOOLS_GN_EDIT_SUBCOMMANDS_H_
diff --git a/src/gn/label_pattern.cc b/src/gn/label_pattern.cc
index 9a21829..6c14c3e 100644
--- a/src/gn/label_pattern.cc
+++ b/src/gn/label_pattern.cc
@@ -273,3 +273,11 @@
   }
   return result;
 }
+
+size_t LabelPattern::hash() const {
+  size_t h0 = static_cast<size_t>(type_);
+  size_t h1 = dir_.hash();
+  size_t h2 = std::hash<std::string>()(name_);
+  size_t h3 = toolchain_.hash();
+  return ((h3 * 131 + h2) * 131 + h1) * 131 + h0;
+}
diff --git a/src/gn/label_pattern.h b/src/gn/label_pattern.h
index 7444ae2..4ef275b 100644
--- a/src/gn/label_pattern.h
+++ b/src/gn/label_pattern.h
@@ -57,6 +57,10 @@
   // Returns a string representation of this pattern.
   std::string Describe() const;
 
+  bool operator==(const LabelPattern& other) const = default;
+
+  size_t hash() const;
+
   Type type() const { return type_; }
 
   const SourceDir& dir() const { return dir_; }
@@ -81,4 +85,13 @@
   std::string name_;
 };
 
+namespace std {
+
+template <>
+struct hash<LabelPattern> {
+  std::size_t operator()(const LabelPattern& v) const { return v.hash(); }
+};
+
+}  // namespace std
+
 #endif  // TOOLS_GN_LABEL_PATTERN_H_
diff --git a/src/gn/parse_tree.h b/src/gn/parse_tree.h
index 471d6cb..08b5dbc 100644
--- a/src/gn/parse_tree.h
+++ b/src/gn/parse_tree.h
@@ -93,6 +93,32 @@
   virtual const LiteralNode* AsLiteral() const;
   virtual const UnaryOpNode* AsUnaryOp() const;
 
+  // We add "Mut" suffixes here because ParseNodes should really not be mutated
+  // without being very intentional about it.
+  // const_cast is safe because this is a non-const method.
+  AccessorNode* AsAccessorMut() {
+    return const_cast<AccessorNode*>(AsAccessor());
+  }
+  BinaryOpNode* AsBinaryOpMut() {
+    return const_cast<BinaryOpNode*>(AsBinaryOp());
+  }
+  BlockCommentNode* AsBlockCommentMut() {
+    return const_cast<BlockCommentNode*>(AsBlockComment());
+  }
+  BlockNode* AsBlockMut() { return const_cast<BlockNode*>(AsBlock()); }
+  ConditionNode* AsConditionMut() {
+    return const_cast<ConditionNode*>(AsCondition());
+  }
+  EndNode* AsEndMut() { return const_cast<EndNode*>(AsEnd()); }
+  FunctionCallNode* AsFunctionCallMut() {
+    return const_cast<FunctionCallNode*>(AsFunctionCall());
+  }
+  IdentifierNode* AsIdentifierMut() {
+    return const_cast<IdentifierNode*>(AsIdentifier());
+  }
+  ListNode* AsListMut() { return const_cast<ListNode*>(AsList()); }
+  LiteralNode* AsLiteralMut() { return const_cast<LiteralNode*>(AsLiteral()); }
+
   virtual Value Execute(Scope* scope, Err* err) const = 0;
 
   virtual LocationRange GetRange() const = 0;
@@ -300,6 +326,7 @@
   const std::vector<std::unique_ptr<ParseNode>>& statements() const {
     return statements_;
   }
+  std::vector<std::unique_ptr<ParseNode>>& statements() { return statements_; }
   void append_statement(std::unique_ptr<ParseNode> s) {
     statements_.push_back(std::move(s));
   }
@@ -394,6 +421,7 @@
   void set_args(std::unique_ptr<ListNode> a);
 
   const BlockNode* block() const { return block_.get(); }
+  BlockNode* block() { return block_.get(); }
   void set_block(std::unique_ptr<BlockNode> b) { block_ = std::move(b); }
 
   void SetNewLocation(int line_number);
@@ -467,6 +495,9 @@
   const std::vector<std::unique_ptr<const ParseNode>>& contents() const {
     return contents_;
   }
+  std::vector<std::unique_ptr<const ParseNode>>& contents() {
+    return contents_;
+  }
 
   void ShortenTargets();
   void SortAsStringsList();
diff --git a/src/gn/setup.cc b/src/gn/setup.cc
index 95e4c19..c0f4c39 100644
--- a/src/gn/setup.cc
+++ b/src/gn/setup.cc
@@ -469,6 +469,23 @@
                  *base::CommandLine::ForCurrentProcess());
 }
 
+bool Setup::DoSetupForEditing() {
+  Err err;
+  if (!FillSourceDir(*base::CommandLine::ForCurrentProcess(), &err)) {
+    err.PrintToStdout();
+    return false;
+  }
+  if (!RunConfigFile(&err)) {
+    err.PrintToStdout();
+    return false;
+  }
+  if (!FillOtherConfig(*base::CommandLine::ForCurrentProcess(), &err)) {
+    err.PrintToStdout();
+    return false;
+  }
+  return true;
+}
+
 bool Setup::DoSetup(const std::string& build_dir,
                     bool force_create,
                     const base::CommandLine& cmdline) {
diff --git a/src/gn/setup.h b/src/gn/setup.h
index 4004891..7f13c44 100644
--- a/src/gn/setup.h
+++ b/src/gn/setup.h
@@ -63,9 +63,9 @@
                       const base::CommandLine& cmdline,
                       Err* err);
 
-  // Setup just enough data for the 'format' command, which doesn't require
+  // Setup just enough data for editing and commands that don't require
   // a build directory.
-  bool DoSetupForFormat();
+  bool DoSetupForEditing();
 
   // Runs the load, returning true on success. On failure, prints the error
   // and returns false. This includes both RunPreMessageLoop() and
diff --git a/src/gn/value.cc b/src/gn/value.cc
index db184ee..2b7a673 100644
--- a/src/gn/value.cc
+++ b/src/gn/value.cc
@@ -52,6 +52,11 @@
 Value::Value(const ParseNode* origin, const char* str_val)
     : type_(STRING), origin_(origin), string_value_(str_val) {}
 
+Value::Value(const ParseNode* origin, std::vector<Value>&& list_val)
+    : type_(LIST), origin_(origin) {
+  new (&list_ptr_) scoped_refptr<ValueList>(new ValueList(std::move(list_val)));
+}
+
 Value::Value(const ParseNode* origin, std::unique_ptr<Scope> scope)
     : type_(SCOPE), origin_(origin), scope_value_(std::move(scope)) {}
 
diff --git a/src/gn/value.h b/src/gn/value.h
index dab3a8e..e1903c9 100644
--- a/src/gn/value.h
+++ b/src/gn/value.h
@@ -51,6 +51,7 @@
   Value(const ParseNode* origin, int64_t int_val);
   Value(const ParseNode* origin, std::string str_val);
   Value(const ParseNode* origin, const char* str_val);
+  Value(const ParseNode* origin, std::vector<Value>&& list_val);
   // Values "shouldn't" have null scopes when type == Scope, so be sure to
   // always set one. However, this is not asserted since there are some
   // use-cases for creating values and immediately setting the scope on it. So
diff --git a/src/util/test/test.h b/src/util/test/test.h
index cc2261d..ebb2b1c 100644
--- a/src/util/test/test.h
+++ b/src/util/test/test.h
@@ -10,6 +10,7 @@
 #include <concepts>
 #include <memory>
 #include <optional>
+#include <set>
 #include <sstream>
 #include <string>
 #include <string_view>
@@ -178,14 +179,27 @@
 }
 
 template <typename T>
+  requires requires(T t) { Pretty(t); }
+std::string Pretty(const std::set<T>& value) {
+  std::stringstream ss;
+  ss << "{\n";
+  for (const auto& v : value) {
+    ss << Indent(Pretty(v)) << ",\n";
+  }
+  ss << "}";
+  return ss.str();
+}
+
+template <typename T>
 std::string Pretty(const Result<T>& result) {
   if (!result.has_value()) {
     return "Err(" + Pretty(result.error()) + ")";
   }
   if constexpr (requires { Pretty(*result); }) {
     return "Ok(" + Pretty(*result) + ")";
+  } else {
+    return "Ok(<unprintable value>)";
   }
-  return "Ok(<unprintable value>)";
 }
 
 template <typename T, typename U>