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);