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 <command> <labels/patterns...>** [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 | <list of build_files...>)** [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>