From 373222fbfbc51721f76cb9fdcb961efac43880aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:33:05 +0000 Subject: [PATCH] fix: escape trigraphs in C and C++ string literals [patch] C up to C17, and C++ before C++17, replace trigraphs such as ??! and ??/ inside string literals, so "what??!" compiled with -std=c11 printed what| and ??/ became a backslash escaping the next character. CFamilyGenerator now overrides EscapeString to write the second ? of every ?? pair as \?, which is a question mark in every C and C++ standard and breaks every trigraph without touching a lone ?. EscapeString becomes virtual for this; the other languages keep the shared escaping, since \? is not an escape in Go, Rust or JavaScript. Fixes #107 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NfxXtpcxX8hkLYrePfHkV4 --- .../Languages/StringLiteralTrigraphTests.cs | 161 ++++++++++++++++++ Coder/Languages/CFamilyGenerator.cs | 29 ++++ Coder/Languages/LanguageGeneratorBase.cs | 2 +- 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 Coder.Test/Languages/StringLiteralTrigraphTests.cs diff --git a/Coder.Test/Languages/StringLiteralTrigraphTests.cs b/Coder.Test/Languages/StringLiteralTrigraphTests.cs new file mode 100644 index 0000000..0c8f32c --- /dev/null +++ b/Coder.Test/Languages/StringLiteralTrigraphTests.cs @@ -0,0 +1,161 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Languages; + +using System.Linq; +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that a string literal holding a trigraph keeps its value in C and C++. +/// +/// +/// C up to C17, and C++ before C++17, replace ??!, ??/ and the other trigraphs even +/// inside a string literal, so "what??!" compiled with -std=c11 prints what|. +/// +[TestClass] +public class StringLiteralTrigraphTests +{ + /// + /// Every trigraph, a run of three question marks, and a lone one that needs no escape. + /// + private const string Value = "what??! a??/b ??=??'??(??)????- ???! ok?"; + + /// + /// The compilers to look for, in the order a C project would. + /// + private static readonly string[] Compilers = ["cc", "gcc", "clang"]; + + /// + /// Tests that C and C++ escape the second question mark of every pair, and nothing else. + /// + /// The generator's language identifier. + [TestMethod] + [DataRow("c")] + [DataRow("cpp")] + public void CFamilyBreaksEveryTrigraph(string language) + { + ILanguageGenerator generator = Generators().Single(g => g.LanguageId == language); + + string code = generator.Generate(Literal.Text(Value)); + + Assert.Contains( + @"""what?\?! a?\?/b ?\?=?\?'?\?(?\?)?\??\?- ?\?\?! ok?""", + code, + StringComparison.Ordinal, + $"{language} wrote {code}"); + Assert.DoesNotContain("??", code, StringComparison.Ordinal, $"{language} left a trigraph in {code}"); + } + + /// + /// Tests that the languages without trigraphs write the question marks as they are, since + /// \? is not an escape in Go, Rust or JavaScript. + /// + /// The generator's language identifier. + [TestMethod] + [DataRow("csharp")] + [DataRow("javascript")] + [DataRow("python")] + [DataRow("go")] + [DataRow("rust")] + public void OtherLanguagesWriteQuestionMarksAsIs(string language) + { + ILanguageGenerator generator = Generators().Single(g => g.LanguageId == language); + + string code = generator.Generate(Literal.Text(Value)); + + Assert.Contains($"\"{Value}\"", code, StringComparison.Ordinal, $"{language} wrote {code}"); + } + + /// + /// Tests that the generated literals hold exactly the characters they were given once a C compiler + /// in a strict standard mode, where trigraphs are on, has read them. + /// + [TestMethod] + public void CompiledWithTrigraphsOn_TheStringsKeepTheirValue() + { + string? compiler = ToolchainHarness.FindOnPath("--version", Compilers); + if (compiler is null) + { + Assert.Inconclusive("No C compiler on the path, so nothing was compiled."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "messages.h"), new CGenerator().Generate(Exemplar())); + + // The driver spells the expected text one character at a time, so it holds no trigraph of + // its own, and reports which string differed through the exit code. + File.WriteAllText(Path.Combine(directory, "driver.c"), """ + #include + #include "messages.h" + + int main(void) + { + static const char exclaim[] = { 'w', 'h', 'a', 't', '?', '?', '!', 0 }; + static const char slash[] = { 'a', '?', '?', '/', 'b', 0 }; + + if (strcmp(Messages_exclaim(), exclaim) != 0) + { + return 1; + } + + if (strcmp(Messages_slash(), slash) != 0) + { + return 2; + } + + return 0; + } + + """); + + (int exitCode, string output) = ToolchainHarness.Run( + compiler, + "-std=c11 -Wall -Wextra -pedantic -Werror=trigraphs driver.c -o driver", + directory); + Assert.AreEqual(0, exitCode, $"{compiler} rejected the generated literals:{Environment.NewLine}{output}"); + + (int runExit, string runOutput) = ToolchainHarness.Run(Path.Combine(directory, "driver"), string.Empty, directory); + Assert.AreEqual(0, runExit, $"String {runExit} changed value under trigraph replacement.{Environment.NewLine}{runOutput}"); + }); + } + + /// + /// Builds a header whose two static members return a literal holding a trigraph. + /// + /// The file to generate. + private static SourceFile Exemplar() + { + SourceFile file = new("messages") { IsHeader = true }; + file.HeaderComment.Add("Generated by Coder. Do not edit."); + + ClassDeclaration messages = new("Messages") { Kind = TypeDeclarationKind.Struct }; + messages.Documentation.Add("Literals that hold a trigraph."); + messages.Members.Add(new VariableDeclaration("unused", "int")); + + FunctionDeclaration exclaim = new("exclaim") { ReturnType = "string", IsStatic = true }; + exclaim.Body.Add(new ReturnStatement(new LiteralExpression("what??!"))); + messages.Members.Add(exclaim); + + FunctionDeclaration slash = new("slash") { ReturnType = "string", IsStatic = true }; + slash.Body.Add(new ReturnStatement(new LiteralExpression("a??/b"))); + messages.Members.Add(slash); + + file.Members.Add(messages); + return file; + } + + private static ILanguageGenerator[] Generators() => + [ + new CSharpGenerator(), + new CGenerator(), + new CppGenerator(), + new GoGenerator(), + new RustGenerator(), + new PythonGenerator(), + new JavaScriptGenerator(), + ]; +} diff --git a/Coder/Languages/CFamilyGenerator.cs b/Coder/Languages/CFamilyGenerator.cs index 8d4ae83..1ad3900 100644 --- a/Coder/Languages/CFamilyGenerator.cs +++ b/Coder/Languages/CFamilyGenerator.cs @@ -170,6 +170,35 @@ protected override void WriteDesignator(string name, CodeBlocker code) protected void WriteBracedList(ConstructionExpression construction, CodeBlocker code, string emptyList) => WriteElementList(construction, code, "{", "}", emptyList); + /// + /// + /// Adds one escape to the shared ones: a ? that follows another ? is written \?. + /// C up to C17, and C++ before C++17, replace a trigraph such as ??! or ??/ even inside + /// a string literal, so "what??!" would read as what| and ??/ would become a + /// backslash that escapes whatever follows it. \? is a question mark in every C and C++ + /// standard, so the string's contents do not change, and escaping only the second of a pair breaks + /// every trigraph while leaving a lone ? as it was written. The override stays here because + /// \? is not an escape in Go, Rust or JavaScript. + /// + protected override string EscapeString(string value) + { + string escaped = base.EscapeString(value); + if (!escaped.Contains("??", StringComparison.Ordinal)) + { + return escaped; + } + + StringBuilder builder = new(escaped.Length + 4); + char previous = '\0'; + foreach (char c in escaped) + { + builder.Append(c == '?' && previous == '?' ? "\\?" : c.ToString()); + previous = c; + } + + return builder.ToString(); + } + /// /// /// Octal, one escape per byte of the character's UTF-8 encoding. \x would read on through diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index 430b772..5a72e9d 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -1042,7 +1042,7 @@ protected static Visibility VisibilityOf(AstNode node) => /// , because written raw they break the literal: Go and Python reject a /// NUL in source, and C# reads U+0085, U+2028 and U+2029 as ending the line. /// - protected string EscapeString(string value) + protected virtual string EscapeString(string value) { Ensure.NotNull(value);