164 lines
7.8 KiB
C#
164 lines
7.8 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Unity.Pipeline.Commands;
|
|
using Unity.Pipeline.Editor.Authoring;
|
|
using Unity.Pipeline.Models;
|
|
using UnityEditor;
|
|
|
|
namespace Unity.Pipeline.Editor.Commands.Scripts
|
|
{
|
|
/// <summary>
|
|
/// Authoring command that writes a new C# MonoBehaviour (or other base-class) script from a
|
|
/// template into the project under the authoring root (CLI-195).
|
|
///
|
|
/// IMPORTANT — the compile/domain-reload boundary:
|
|
/// Writing a .cs file does NOT make the type available. Unity must import and compile the new
|
|
/// file, which triggers a domain reload, before the type exists and can be attached. The agent
|
|
/// workflow is therefore:
|
|
/// 1. create_script (this command — writes the file)
|
|
/// 2. recompile (triggers compilation; see <see cref="RecompileCommand"/>)
|
|
/// 3. poll recompile_status (wait until "completed" / "up_to_date")
|
|
/// 4. attach_script (now the type exists; see <see cref="AttachScriptCommand"/>)
|
|
/// This command intentionally does its part only: it creates the file and returns the asset
|
|
/// identity. It does not trigger a recompile itself — the agent owns that step so it can batch
|
|
/// multiple authoring writes before paying the domain-reload cost once.
|
|
/// </summary>
|
|
public static class CreateScriptCommand
|
|
{
|
|
/// <summary>
|
|
/// Default class body (the Start/Update stubs) written inside the generated class, matching
|
|
/// Unity's own new-MonoBehaviour template so the output reads like a hand-authored script.
|
|
/// <see cref="BuildSource"/> wraps it with the using/class declaration and an optional namespace.
|
|
/// </summary>
|
|
private const string DefaultBody =
|
|
" // Use this for initialization\n" +
|
|
" void Start()\n" +
|
|
" {\n\n" +
|
|
" }\n\n" +
|
|
" // Update is called once per frame\n" +
|
|
" void Update()\n" +
|
|
" {\n\n" +
|
|
" }\n";
|
|
|
|
[CliCommand("create_script",
|
|
"Create a new C# script (default base class MonoBehaviour) from a template under the authoring root. " +
|
|
"NOTE: the type does not exist until a recompile completes — to attach it, call recompile, poll recompile_status, then attach_script.")]
|
|
public static AuthoringResult CreateScript(
|
|
[CliArg("name", "Class/file name without extension, e.g. PlayerController. Must be a valid C# identifier.", Required = true)] string name,
|
|
[CliArg("path", "Folder (relative to the authoring root; the Assets/ prefix is optional) to write the .cs into. Defaults to the authoring root.")] string path = null,
|
|
[CliArg("namespace", "Optional namespace to wrap the class in. Omit for the global namespace.")] string @namespace = null,
|
|
[CliArg("base_class", "Base class to derive from. Defaults to MonoBehaviour.")] string baseClass = "MonoBehaviour",
|
|
[CliArg("overwrite", "Overwrite the file if it already exists. Defaults to false (an existing file is an error).")] bool overwrite = false)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
throw new ArgumentException("Script 'name' is required.");
|
|
|
|
var className = name.Trim();
|
|
if (className.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
|
className = className.Substring(0, className.Length - 3);
|
|
|
|
if (!IsValidIdentifier(className))
|
|
throw new ArgumentException($"Script name '{className}' is not a valid C# class identifier.");
|
|
|
|
if (string.IsNullOrWhiteSpace(baseClass))
|
|
baseClass = "MonoBehaviour";
|
|
|
|
// Resolve & sandbox the destination folder through the project-path policy. A null/empty
|
|
// path means "the authoring root itself" — Resolve treats an empty path as an error, so
|
|
// fall back to the configured root in that case (it is already a confined, valid path).
|
|
string folder;
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
folder = ProjectPaths.AuthoringRoot;
|
|
}
|
|
else
|
|
{
|
|
folder = ProjectPaths.Resolve(path, out var error);
|
|
if (folder == null)
|
|
throw new ArgumentException(error);
|
|
}
|
|
|
|
if (!AssetDatabase.IsValidFolder(folder))
|
|
throw new ArgumentException(
|
|
$"Destination folder '{folder}' does not exist. Create it first with create_folder.");
|
|
|
|
var assetPath = $"{folder}/{className}.cs";
|
|
if (File.Exists(ToAbsolute(assetPath)) && !overwrite)
|
|
throw new ArgumentException(
|
|
$"A script already exists at '{assetPath}'. Pass overwrite=true to replace it.");
|
|
|
|
var source = BuildSource(className, @namespace, baseClass);
|
|
|
|
// Write through the filesystem then import: AssetDatabase has no "create text asset" API,
|
|
// and CreateAsset is for UnityEngine.Object instances, not source files.
|
|
File.WriteAllText(ToAbsolute(assetPath), source, new UTF8Encoding(false));
|
|
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
|
|
|
|
// The MonoScript may already be loadable (its asset entry exists), but its compiled Type
|
|
// will not exist until the next domain reload — see the class doc.
|
|
var script = AssetDatabase.LoadAssetAtPath<MonoScript>(assetPath);
|
|
var result = ObjectResolver.Describe(script) ?? new AuthoringResult { Type = "MonoScript" };
|
|
result.AssetPath = assetPath;
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the script source from the template, substituting the class name, optional namespace
|
|
/// and base class. When a namespace is supplied the class body is indented one extra level.
|
|
/// </summary>
|
|
private static string BuildSource(string className, string @namespace, string baseClass)
|
|
{
|
|
var usings = "using UnityEngine;\n\n";
|
|
var classDecl = $"public class {className} : {baseClass}\n";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.Append(usings);
|
|
|
|
if (!string.IsNullOrWhiteSpace(@namespace))
|
|
{
|
|
sb.Append($"namespace {@namespace}\n{{\n");
|
|
sb.Append(Indent(classDecl, " "));
|
|
sb.Append(" {\n");
|
|
sb.Append(Indent(DefaultBody, " "));
|
|
sb.Append(" }\n");
|
|
sb.Append("}\n");
|
|
}
|
|
else
|
|
{
|
|
sb.Append(classDecl);
|
|
sb.Append("{\n");
|
|
sb.Append(DefaultBody);
|
|
sb.Append("}\n");
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string Indent(string text, string prefix)
|
|
{
|
|
var sb = new StringBuilder();
|
|
foreach (var line in text.Split('\n'))
|
|
sb.Append(line.Length == 0 ? "\n" : prefix + line + "\n");
|
|
// Split adds a trailing empty element for a trailing newline; trim the extra newline we
|
|
// appended for it.
|
|
if (text.EndsWith("\n") && sb.Length > 0)
|
|
sb.Length -= 1;
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static bool IsValidIdentifier(string value)
|
|
{
|
|
// A C# identifier: starts with a letter or underscore, then letters/digits/underscores.
|
|
return Regex.IsMatch(value, @"^[A-Za-z_][A-Za-z0-9_]*$");
|
|
}
|
|
|
|
private static string ToAbsolute(string assetPath)
|
|
{
|
|
// assetPath is project-relative ("Assets/..."); ProjectRoot is the folder containing Assets/.
|
|
return Path.Combine(ProjectPaths.ProjectRoot, assetPath).Replace('\\', '/');
|
|
}
|
|
}
|
|
}
|