Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
bef788f
updated semantic token visitor to improve character highlighting
parisba Jan 8, 2026
b2ff70d
changes in support of new lsp
parisba Jan 14, 2026
57b0534
changes for error handling
parisba Jan 14, 2026
b561608
vastly improving errors
parisba Jan 14, 2026
ecbaa2d
error handling mirpvements
parisba Jan 14, 2026
8e93877
fixing highlighting errors
parisba Jan 14, 2026
17d066f
Remove deprecated C# language server
parisba Jan 14, 2026
a70a1f3
Merge branch 'main' of github.com:YarnSpinnerTool/YarnSpinner into pa…
desplesda Jan 15, 2026
9721d0d
Remove old language server csproj references from solution
desplesda Jan 15, 2026
ab393be
Only fail a test if it contains errors, not _any_ diagnostic
desplesda Jan 15, 2026
f736464
updates to nodemetadatavisitor per Jon's PR comments
parisba Jan 16, 2026
5bedeb2
comment clarifying why we dont emit YS00001 implicit variable type co…
parisba Jan 16, 2026
979ac45
big fancy central diagnostic descriptor which was already in flight w…
parisba Jan 16, 2026
3bc5a87
Merge branch 'paris-improvements-forvscode-and-playground' of https:/…
parisba Jan 16, 2026
6ecda60
fixing ys003 and node group comments to clarify
parisba Jan 16, 2026
d565e3d
more error work
parisba Jan 16, 2026
1687ffa
Add format placeholder descriptions for diagnostic descriptors
desplesda Jan 16, 2026
ca9da28
Add regions to DiagnosticDescriptor
desplesda Jan 17, 2026
25f3a26
Add additional error codes
desplesda Jan 17, 2026
1822d67
Allow creating diagnostics via a DiagnosticDescriptor
desplesda Jan 17, 2026
7d33795
Start making diagnostic generation use descriptors instead of raw str…
desplesda Jan 17, 2026
e444897
Fix runtime error due to duplicate placeholder error codes
desplesda Jan 17, 2026
e98a618
Document DiagnosticDescriptor.Create overloads
desplesda Jan 17, 2026
e1ff83a
Merge remote-tracking branch 'origin/paris-improvements-forvscode-and…
parisba Jan 17, 2026
6b94440
Remove merge conflict marker
desplesda Jan 17, 2026
7215041
Fix incorrect test
desplesda Jan 17, 2026
6f73632
changes to support grouping options in VSCode
parisba Jan 19, 2026
6e5a1ce
Merge branch 'paris-improvements-forvscode-and-playground' of https:/…
parisba Jan 19, 2026
dfccf51
Include source URIs in jumps
desplesda Jan 19, 2026
cbc3bbc
Temporarily skip TestLineCollisionTagging until perf fix for SyntaxVa…
desplesda Jan 19, 2026
fe7cc4c
Fix test failure in TestInterruptedLinesNotTagged
desplesda Jan 19, 2026
62b8b91
Convert more compile errors to use DiagnosticDescriptor
desplesda Jan 19, 2026
c3a2deb
commenting out when from preview features..
parisba Jan 20, 2026
8dc14e0
Merge branch 'paris-improvements-forvscode-and-playground' of https:/…
parisba Jan 20, 2026
f0aca49
fixes and tweaks in support of try/playgrounds/new lsp
parisba Jan 29, 2026
7c66217
fixes in support of issues found in try
parisba Jan 29, 2026
8c729b9
tweaks for try
parisba Jan 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions YarnSpinner.Compiler/CompilationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,5 +188,11 @@ internal string DumpProgram()
/// </summary>
public IEnumerable<FileParseResult> ParseResults { get; internal set; } = Array.Empty<FileParseResult>();

/// <summary>
/// contains metadata about all nodes extracted during compilation for use by language server features
/// includes information about jumps, function calls, commands, variables, character names, tags, and structural information
/// </summary>
public IEnumerable<NodeMetadata> NodeMetadata { get; internal set; } = Array.Empty<NodeMetadata>();

}
}
63 changes: 56 additions & 7 deletions YarnSpinner.Compiler/Compiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ public static CompilationResult Compile(CompilationJob compilationJob)
// No source line found
diagnostics.Add(new Diagnostic(
sourceFile, shadowLineContext, $"\"{shadowLineID}\" is not a known line ID."
));
) { Code = "YS0014" });
continue;
}

Expand All @@ -178,15 +178,15 @@ public static CompilationResult Compile(CompilationJob compilationJob)
// Lines must not have inline expressions
diagnostics.Add(new Diagnostic(
sourceFile, shadowLineContext, $"Shadow lines must not have expressions"
));
) { Code = "YS0016" });
}

if (sourceText.Equals(shadowText, StringComparison.CurrentCulture) == false)
{
// Lines must be identical
diagnostics.Add(new Diagnostic(
sourceFile, shadowLineContext, $"Shadow lines must have the same text as their source lines"
));
) { Code = "YS0015" });
}

// The shadow line is valid. Strip the text from its StringInfo,
Expand All @@ -213,6 +213,37 @@ public static CompilationResult Compile(CompilationJob compilationJob)
nodeGroupVisitor.Visit(file.Tree);
}

// extract node metadata for language server features
// this includes jumps, function calls, commands, variables, character names, tags, and structural info
var nodeMetadata = new List<NodeMetadata>();
foreach (var file in parsedFiles)
{
if (file.Tree.Payload is YarnSpinnerParser.DialogueContext dialogueContext)
{
var metadata = NodeMetadataVisitor.Extract(file.Name, dialogueContext);
nodeMetadata.AddRange(metadata);
}
}

// validate jump targets - YS0002: warn about jumps to non-existent nodes
var allNodeTitles = new HashSet<string>(nodeMetadata.Select(n => n.Title));
foreach (var node in nodeMetadata)
{
foreach (var jump in node.Jumps)
{
if (!string.IsNullOrWhiteSpace(jump.DestinationTitle) && !allNodeTitles.Contains(jump.DestinationTitle))
{
// Use the jump's precise range for accurate error highlighting
diagnostics.Add(new Diagnostic(
node.Uri,
jump.Range,
$"Node '{jump.DestinationTitle}' does not exist. Check spelling or create the node.",
Diagnostic.DiagnosticSeverity.Warning
) { Code = "YS0002" });
}
}
}

if (compilationJob.CompilationType == CompilationJob.Type.StringsOnly)
{
// Stop at this point
Expand All @@ -224,6 +255,7 @@ public static CompilationResult Compile(CompilationJob compilationJob)
StringTable = stringTableManager.StringTable,
Diagnostics = diagnostics,
ParseResults = parsedFiles,
NodeMetadata = nodeMetadata,
};
}

Expand All @@ -242,6 +274,8 @@ public static CompilationResult Compile(CompilationJob compilationJob)
var failingConstraints = new HashSet<TypeConstraint>();

var walker = new Antlr4.Runtime.Tree.ParseTreeWalker();

// Type check all files
foreach (var parsedFile in parsedFiles)
{
compilationJob.CancellationToken.ThrowIfCancellationRequested();
Expand All @@ -259,6 +293,19 @@ public static CompilationResult Compile(CompilationJob compilationJob)
failingConstraints = new HashSet<TypeConstraint>(TypeCheckerListener.ApplySolution(typeSolution, failingConstraints));
}

// After all files are type-checked, check for variables that are still implicitly declared
// (i.e., were used but never had a <<declare>> statement in any file)
// YS0001: Variable used without being declared
foreach (var declaration in declarations.Where(d => d.IsImplicit))
{
diagnostics.Add(new Diagnostic(
declaration.SourceFileName,
declaration.Range,
$"Variable '{declaration.Name}' is used but not declared. Declare it with: <<declare {declaration.Name} = value>>",
Diagnostic.DiagnosticSeverity.Warning
) { Code = "YS0001" });
}

if (failingConstraints.Count > 0)
{
// We have a number of constraints that we were unable to
Expand Down Expand Up @@ -312,7 +359,7 @@ public static CompilationResult Compile(CompilationJob compilationJob)
{
foreach (var failureMessage in constraint.GetFailureMessages(typeSolution))
{
diagnostics.Add(new Yarn.Compiler.Diagnostic(constraint.SourceFileName, constraint.SourceRange, failureMessage));
diagnostics.Add(new Yarn.Compiler.Diagnostic(constraint.SourceFileName, constraint.SourceRange, failureMessage) { Code = "YS0010" });
}
}
watchdog.Stop();
Expand Down Expand Up @@ -483,6 +530,7 @@ public static CompilationResult Compile(CompilationJob compilationJob)
Diagnostics = diagnostics,
UserDefinedTypes = userDefinedTypes,
ParseResults = parsedFiles,
NodeMetadata = nodeMetadata,
};
}

Expand Down Expand Up @@ -653,6 +701,7 @@ public static CompilationResult Compile(CompilationJob compilationJob)
ProjectDebugInfo = projectDebugInfo,
UserDefinedTypes = userDefinedTypes,
ParseResults = parsedFiles,
NodeMetadata = nodeMetadata,
};

return finalResult;
Expand Down Expand Up @@ -851,7 +900,7 @@ bool HasWhenHeader(YarnSpinnerParser.NodeContext nodeContext)
// More than one node has this name! Report an error on both.
foreach (var entry in group)
{
var d = new Diagnostic(entry.File.Name, entry.TitleHeader, $"More than one node is named {entry.Name}");
var d = new Diagnostic(entry.File.Name, entry.TitleHeader, $"More than one node is named {entry.Name}") { Code = "YS0003" };
diagnostics.Add(d);
}
}
Expand All @@ -861,11 +910,11 @@ bool HasWhenHeader(YarnSpinnerParser.NodeContext nodeContext)
{
if (node.Node.NodeTitle == null)
{
diagnostics.Add(new Diagnostic(node.File.Name, node.Node.body(), $"Nodes must have a title"));
diagnostics.Add(new Diagnostic(node.File.Name, node.Node.body(), $"Nodes must have a title") { Code = "YS0011" });
}
if (node.Node.title_header().Length > 1)
{
diagnostics.Add(new Diagnostic(node.File.Name, node.Node.title_header()[1], $"Nodes must have a single title node"));
diagnostics.Add(new Diagnostic(node.File.Name, node.Node.title_header()[1], $"Nodes must have a single title node") { Code = "YS0011" });
}
}
}
Expand Down
55 changes: 54 additions & 1 deletion YarnSpinner.Compiler/ErrorListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ public sealed class Diagnostic
/// </summary>
public DiagnosticSeverity Severity { get; set; } = DiagnosticSeverity.Error;

/// <summary>
/// gets or sets the error code for this diagnostic
/// error codes help users look up documentation and categorise issues
/// follows the format YS0001, YS0002, etc
/// </summary>
public string? Code { get; set; } = null;

/// <summary>
/// Gets the zero-indexed line number in FileName at which the issue
/// begins.
Expand Down Expand Up @@ -259,7 +266,15 @@ public LexerErrorListener(string fileName) : base()
public void SyntaxError(TextWriter output, IRecognizer recognizer, int offendingSymbol, int line, int charPositionInLine, string msg, RecognitionException e)
{
Range range = new Range(line - 1, charPositionInLine, line - 1, charPositionInLine + 1);
this.diagnostics.Add(new Diagnostic(this.fileName, range, msg));
var diagnostic = new Diagnostic(this.fileName, range, msg);

// Assign error code for lexer errors (typically YS0005 for token recognition)
if (msg.ToLowerInvariant().Contains("token recognition error"))
{
diagnostic.Code = "YS0005";
}

this.diagnostics.Add(diagnostic);
}
}

Expand Down Expand Up @@ -319,7 +334,45 @@ public override void SyntaxError(System.IO.TextWriter output, IRecognizer recogn
diagnostic.Range = new Range(offendingSymbol.Line - 1, offendingSymbol.Column, offendingSymbol.Line - 1, offendingSymbol.Column + offendingSymbol.Text.Length);
}

// Assign error codes based on ANTLR message patterns
diagnostic.Code = CategorizeParserError(msg);

this.diagnostics.Add(diagnostic);
}

/// <summary>
/// Categorizes parser errors from ANTLR messages and assigns appropriate error codes
/// </summary>
private string? CategorizeParserError(string message)
{
var msg = message.ToLowerInvariant();

// YS0004: Missing delimiter (=== or ---)
if (msg.Contains("missing") && (msg.Contains("===") || msg.Contains("'==='") || msg.Contains("delimiter")))
{
return "YS0004";
}

// YS0006: Unclosed command (missing >>)
if (msg.Contains("missing") && (msg.Contains("'>'") || msg.Contains(">>")))
{
return "YS0006";
}

// YS0007: Unclosed scope (missing endif, endonce, etc)
if (msg.Contains("missing") && (msg.Contains("endif") || msg.Contains("endonce") || msg.Contains("end")))
{
return "YS0007";
}

// YS0005: Malformed dialogue / syntax error
if (msg.Contains("extraneous input") || msg.Contains("mismatched input"))
{
return "YS0005";
}

// Default: no specific code for other ANTLR errors
return null;
}
}
}
130 changes: 130 additions & 0 deletions YarnSpinner.Compiler/NodeMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright Yarn Spinner Pty Ltd
// Licensed under the MIT License. See LICENSE.md in project root for license information.

namespace Yarn.Compiler
{
using System.Collections.Generic;

/// <summary>
/// contains metadata about a node extracted during compilation for use by language server features
/// this is different from nodedebuginfo which is for instruction level debugging
/// </summary>
public class NodeMetadata
{
/// <summary>
/// the title of the node
/// </summary>
public string Title { get; set; } = string.Empty;

/// <summary>
/// the file uri where this node is defined
/// </summary>
public string Uri { get; set; } = string.Empty;

/// <summary>
/// all jump and detour destinations referenced from this node
/// </summary>
public List<JumpInfo> Jumps { get; set; } = new List<JumpInfo>();

/// <summary>
/// all function names called within this node
/// </summary>
public List<string> FunctionCalls { get; set; } = new List<string>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether it would be useful to store names alongside locations (i.e. paths and ranges), rather than simply the function identifier, and likewise for variables, nodes, etc. (Not a requirement, just a thought.)


/// <summary>
/// all command names called within this node (excludes flow control like if/else/endif)
/// </summary>
public List<string> CommandCalls { get; set; } = new List<string>();

/// <summary>
/// all variable names referenced within this node
/// </summary>
public List<string> VariableReferences { get; set; } = new List<string>();

/// <summary>
/// complexity score for node groups or negative one if not part of a group
/// calculated by the compiler based on when clauses
/// </summary>
public int NodeGroupComplexity { get; set; } = -1;

/// <summary>
/// character names found in dialogue lines within this node
/// extracted from lines matching the pattern charactername: dialogue
/// </summary>
public List<string> CharacterNames { get; set; } = new List<string>();

/// <summary>
/// tags from the node tags header
/// </summary>
public List<string> Tags { get; set; } = new List<string>();

/// <summary>
/// preview text from the first few lines of dialogue in the node
/// used for quick previews in ui
/// </summary>
public string PreviewText { get; set; } = string.Empty;

/// <summary>
/// number of shortcut options in this node
/// </summary>
public int OptionCount { get; set; } = 0;

/// <summary>
/// zero based line number where the node header starts (first three dashes)
/// </summary>
public int HeaderStartLine { get; set; } = -1;

/// <summary>
/// zero based line number where the title declaration is (title: nodename)
/// </summary>
public int TitleLine { get; set; } = -1;

/// <summary>
/// zero based line number where the node body starts (after second three dashes)
/// </summary>
public int BodyStartLine { get; set; } = -1;

/// <summary>
/// zero based line number where the node body ends (at or before three equals signs)
/// </summary>
public int BodyEndLine { get; set; } = -1;
}

/// <summary>
/// information about a jump or detour from one node to another
/// </summary>
public class JumpInfo
{
/// <summary>
/// the title of the destination node
/// </summary>
public string DestinationTitle { get; set; } = string.Empty;

/// <summary>
/// whether this is a jump or a detour
/// </summary>
public JumpType Type { get; set; }

/// <summary>
/// the source location of this jump statement in the file
/// used for precise error reporting (YS0002)
/// </summary>
public Range Range { get; set; } = Range.InvalidRange;
}

/// <summary>
/// type of jump between nodes
/// </summary>
public enum JumpType
{
/// <summary>
/// a standard jump to another node
/// </summary>
Jump,

/// <summary>
/// a detour to another node that will return
/// </summary>
Detour
}
}
8 changes: 6 additions & 2 deletions YarnSpinner.Compiler/TypeCheckerListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,10 @@ public override void ExitVariable([NotNull] YarnSpinnerParser.VariableContext co
SourceNodeName = this.currentNodeName,
};
this.AddDeclaration(declaration);

// NOTE: We don't emit YS0001 here because this variable might be explicitly declared
// in a different file that hasn't been type-checked yet. We'll check for implicit
// declarations after all files are processed and emit warnings then.
}

context.Type = declaration.Type;
Expand Down Expand Up @@ -793,7 +797,7 @@ public override void ExitFunction_call([NotNull] YarnSpinnerParser.Function_call
message = $"{functionName} expects {expectedParameters} {(expectedEnglishPlural ? "parameters" : "parameter")}, not {actualParameters}";
}

this.diagnostics.Add(new Diagnostic(this.sourceFileName, context, message));
this.diagnostics.Add(new Diagnostic(this.sourceFileName, context, message) { Code = "YS0009" });
}

for (int paramID = 0; paramID < actualParameters; paramID++)
Expand All @@ -808,7 +812,7 @@ public override void ExitFunction_call([NotNull] YarnSpinnerParser.Function_call
}
catch (ArgumentOutOfRangeException)
{
this.diagnostics.Add(new Diagnostic(this.sourceFileName, parameterExpression, "Unexpected parameter in call to function " + functionName ?? "<unknown>"));
this.diagnostics.Add(new Diagnostic(this.sourceFileName, parameterExpression, "Unexpected parameter in call to function " + functionName ?? "<unknown>") { Code = "YS0009" });
}
}

Expand Down
Loading
Loading