using System; using System.Collections.Generic; using System.IO; using System.Linq; using Yarn.Compiler; using Yarn.Unity; using Yarn.Unity.Editor; namespace AibisDream.YarnLocalizationValidation.Editor { public static class YarnL10nValidator { public static YarnL10nValidationResult ValidateFolder(string folderPath) { var result = new YarnL10nValidationResult(); if (string.IsNullOrWhiteSpace(folderPath)) { AddIssue(result, CreateIssue( YarnL10nIssueType.InvalidProject, chapterFolder: folderPath, message: "章节文件夹路径为空。")); FinalizeResult(result); return result; } var normalizedFolder = NormalizeProjectRelativePath(folderPath); var absoluteFolder = ToAbsolutePath(normalizedFolder); if (!Directory.Exists(absoluteFolder)) { AddIssue(result, CreateIssue( YarnL10nIssueType.InvalidProject, chapterFolder: normalizedFolder, message: $"文件夹不存在: {normalizedFolder}")); FinalizeResult(result); return result; } var yarnProjects = Directory.GetFiles(absoluteFolder, "*.yarnproject", SearchOption.TopDirectoryOnly); if (yarnProjects.Length == 0) { AddIssue(result, CreateIssue( YarnL10nIssueType.InvalidProject, chapterFolder: normalizedFolder, message: "文件夹内未找到 .yarnproject 文件。")); FinalizeResult(result); return result; } if (yarnProjects.Length > 1) { AddIssue(result, CreateIssue( YarnL10nIssueType.InvalidProject, chapterFolder: normalizedFolder, message: $"文件夹内存在多个 .yarnproject({yarnProjects.Length} 个)。")); FinalizeResult(result); return result; } ValidateYarnProjectFile(yarnProjects[0], result); FinalizeResult(result); return result; } public static YarnL10nValidationResult ValidateScanAll(string rootPath) { var result = new YarnL10nValidationResult(); var normalizedRoot = NormalizeProjectRelativePath(rootPath); var absoluteRoot = ToAbsolutePath(normalizedRoot); if (!Directory.Exists(absoluteRoot)) { AddIssue(result, CreateIssue( YarnL10nIssueType.InvalidProject, chapterFolder: normalizedRoot, message: $"扫描根目录不存在: {normalizedRoot}")); FinalizeResult(result); return result; } var yarnProjectFiles = Directory.GetFiles(absoluteRoot, "*.yarnproject", SearchOption.AllDirectories); if (yarnProjectFiles.Length == 0) { AddIssue(result, CreateIssue( YarnL10nIssueType.InvalidProject, chapterFolder: normalizedRoot, message: "扫描范围内未找到任何 .yarnproject。")); FinalizeResult(result); return result; } foreach (var yarnProjectFile in yarnProjectFiles.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) { ValidateYarnProjectFile(yarnProjectFile, result); } FinalizeResult(result); return result; } private static void ValidateYarnProjectFile(string yarnProjectAbsolutePath, YarnL10nValidationResult result) { var chapterFolder = NormalizeProjectRelativePath(Path.GetDirectoryName(yarnProjectAbsolutePath)); var yarnProjectRelativePath = NormalizeProjectRelativePath(yarnProjectAbsolutePath); Project project; try { project = Project.LoadFromFile(yarnProjectAbsolutePath); } catch (Exception ex) { AddIssue(result, CreateIssue( YarnL10nIssueType.InvalidProject, chapterFolder: chapterFolder, message: $"无法解析 .yarnproject: {ex.Message}")); return; } var chapterSummary = new YarnL10nChapterSummary { ChapterFolder = chapterFolder, YarnProjectPath = yarnProjectRelativePath, }; var issueStartIndex = result.Issues.Count; if (project.Localisation == null || project.Localisation.Count == 0) { AddIssue(result, CreateIssue( YarnL10nIssueType.NoLocalizationConfigured, chapterFolder: chapterFolder, message: "yarnproject 未配置 localisation。")); result.Chapters.Add(chapterSummary); return; } var sourceFiles = project.SourceFiles?.ToList() ?? new List(); if (sourceFiles.Count == 0) { AddIssue(result, CreateIssue( YarnL10nIssueType.InvalidProject, chapterFolder: chapterFolder, message: "yarnproject 未包含任何 .yarn 源文件。")); result.Chapters.Add(chapterSummary); return; } var job = CompilationJob.CreateFromFiles(sourceFiles); job.CompilationType = CompilationJob.Type.StringsOnly; var compilationResult = Compiler.Compile(job); foreach (var diagnostic in compilationResult.Diagnostics.Where(d => d.Severity == Diagnostic.DiagnosticSeverity.Error)) { AddIssue(result, CreateIssue( YarnL10nIssueType.CompileError, chapterFolder: chapterFolder, message: diagnostic.Message)); } if (compilationResult.ContainsErrors) { result.Chapters.Add(chapterSummary); return; } if (compilationResult.ContainsImplicitStringTags) { foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.isImplicitTag)) { AddIssue(result, CreateIssue( YarnL10nIssueType.UntaggedLine, chapterFolder: chapterFolder, lineId: pair.Key, yarnFile: pair.Value.fileName, yarnLineNumber: pair.Value.lineNumber.ToString(), message: "存在未打 #line: 标签的可本地化行。")); } } var baseEntries = BuildBaseEntries(project, compilationResult); chapterSummary.SourceLineCount = baseEntries.Count; foreach (var localePair in project.Localisation) { var language = localePair.Key; if (string.Equals(language, project.BaseLanguage, StringComparison.OrdinalIgnoreCase)) { continue; } if (string.IsNullOrEmpty(localePair.Value.Strings)) { continue; } if (localePair.Value.Strings.StartsWith("unity:", StringComparison.Ordinal)) { continue; } var localeSummary = new YarnL10nLocaleSummary { Language = language }; chapterSummary.Locales[language] = localeSummary; if (!project.TryGetStringsPath(language, out var csvRelativePath) || string.IsNullOrEmpty(csvRelativePath)) { AddIssue(result, CreateIssue( YarnL10nIssueType.CsvMissing, chapterFolder: chapterFolder, language: language, message: $"未找到语言 {language} 的 CSV 路径。")); continue; } var csvAbsolutePath = ToAbsolutePath(csvRelativePath); if (!File.Exists(csvAbsolutePath)) { AddIssue(result, CreateIssue( YarnL10nIssueType.CsvMissing, chapterFolder: chapterFolder, language: language, csvFile: csvRelativePath, message: $"本地化 CSV 不存在: {csvRelativePath}")); continue; } IEnumerable translatedEntries; try { translatedEntries = StringTableEntry.ParseFromCSV(File.ReadAllText(csvAbsolutePath)); } catch (Exception ex) { AddIssue(result, CreateIssue( YarnL10nIssueType.InvalidProject, chapterFolder: chapterFolder, language: language, csvFile: csvRelativePath, message: $"无法解析 CSV: {ex.Message}")); continue; } var translatedDictionary = translatedEntries .Where(entry => !string.IsNullOrEmpty(entry.ID)) .GroupBy(entry => entry.ID) .ToDictionary(group => group.Key, group => group.First()); var baseIds = baseEntries.Keys.ToHashSet(); var translatedIds = translatedDictionary.Keys.ToHashSet(); foreach (var missingId in baseIds.Except(translatedIds).OrderBy(id => id, StringComparer.Ordinal)) { var baseEntry = baseEntries[missingId]; localeSummary.MissingCount++; AddIssue(result, CreateIssue( YarnL10nIssueType.MissingLocalization, chapterFolder: chapterFolder, language: language, lineId: missingId, sourceText: baseEntry.Text, yarnFile: baseEntry.File, yarnLineNumber: baseEntry.LineNumber)); } foreach (var orphanId in translatedIds.Except(baseIds).OrderBy(id => id, StringComparer.Ordinal)) { var translatedEntry = translatedDictionary[orphanId]; localeSummary.OrphanCount++; AddIssue(result, CreateIssue( YarnL10nIssueType.OrphanLocalization, chapterFolder: chapterFolder, language: language, lineId: orphanId, locText: translatedEntry.Text, csvFile: csvRelativePath)); } foreach (var sharedId in baseIds.Intersect(translatedIds)) { var baseEntry = baseEntries[sharedId]; var translatedEntry = translatedDictionary[sharedId]; if (string.IsNullOrEmpty(baseEntry.Lock) || string.IsNullOrEmpty(translatedEntry.Lock)) { continue; } if (baseEntry.Lock == translatedEntry.Lock) { continue; } localeSummary.SourceChangedCount++; AddIssue(result, CreateIssue( YarnL10nIssueType.SourceTextChanged, chapterFolder: chapterFolder, language: language, lineId: sharedId, sourceText: baseEntry.Text, locText: translatedEntry.Text, yarnFile: baseEntry.File, yarnLineNumber: baseEntry.LineNumber, csvFile: csvRelativePath, message: "原文 Lock 与翻译 CSV 不一致,翻译可能已过期。")); } } chapterSummary.IssueCount = result.Issues.Count - issueStartIndex; result.Chapters.Add(chapterSummary); } private static Dictionary BuildBaseEntries(Project project, CompilationResult compilationResult) { var entries = new Dictionary(StringComparer.Ordinal); foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.text != null)) { var entry = new StringTableEntry { ID = pair.Key, Language = project.BaseLanguage ?? "zh-Hans", Text = pair.Value.text, File = pair.Value.fileName, Node = pair.Value.nodeName, LineNumber = pair.Value.lineNumber.ToString(), Lock = YarnImporter.GetHashString(pair.Value.text!, 8), }; entries[pair.Key] = entry; } return entries; } private static YarnL10nIssue CreateIssue( YarnL10nIssueType type, string chapterFolder = "", string language = "", string lineId = "", string sourceText = "", string locText = "", string yarnFile = "", string yarnLineNumber = "", string csvFile = "", string message = "") { return new YarnL10nIssue { Type = type, ChapterFolder = chapterFolder ?? string.Empty, Language = language ?? string.Empty, LineId = lineId ?? string.Empty, SourceText = sourceText ?? string.Empty, LocText = locText ?? string.Empty, YarnFile = yarnFile ?? string.Empty, YarnLineNumber = yarnLineNumber ?? string.Empty, CsvFile = csvFile ?? string.Empty, Message = message ?? string.Empty, }; } private static void AddIssue(YarnL10nValidationResult result, YarnL10nIssue issue) { result.Issues.Add(issue); } private static void FinalizeResult(YarnL10nValidationResult result) { result.TotalIssueCount = result.Issues.Count; } public static string NormalizeProjectRelativePath(string path) { if (string.IsNullOrWhiteSpace(path)) { return string.Empty; } var normalized = path.Replace('\\', '/').Trim(); var projectRoot = GetProjectRootPath().Replace('\\', '/'); if (Path.IsPathRooted(normalized)) { var absolute = Path.GetFullPath(normalized).Replace('\\', '/'); if (absolute.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase)) { normalized = absolute.Substring(projectRoot.Length).TrimStart('/'); } else { normalized = absolute; } } return normalized.TrimEnd('/'); } public static string ToAbsolutePath(string projectRelativeOrAbsolutePath) { if (string.IsNullOrWhiteSpace(projectRelativeOrAbsolutePath)) { return GetProjectRootPath(); } if (Path.IsPathRooted(projectRelativeOrAbsolutePath)) { return Path.GetFullPath(projectRelativeOrAbsolutePath); } return Path.GetFullPath(Path.Combine(GetProjectRootPath(), projectRelativeOrAbsolutePath)); } private static string GetProjectRootPath() { return Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, "..")); } } }