diff --git a/src/lang/Nub.Lang/Source.cs b/src/lang/Nub.Lang/Source.cs index caf7c61..4fc1410 100644 --- a/src/lang/Nub.Lang/Source.cs +++ b/src/lang/Nub.Lang/Source.cs @@ -2,55 +2,249 @@ using System.Diagnostics.CodeAnalysis; namespace Nub.Lang; -public readonly struct SourceSpan(SourceText text, SourceLocation start, SourceLocation end) : IEquatable +/// +/// Represents a location in source code with line and column information. +/// Lines and columns are 1-based to match typical editor conventions. +/// +public readonly struct SourceLocation : IEquatable, IComparable { - public SourceText Text { get; } = text; - public SourceLocation Start { get; } = start; - public SourceLocation End { get; } = end; - - /// - /// Merges one or more from a single file to a single - /// - /// The spans to merged - /// The merged - public static SourceSpan Merge(IEnumerable spanEnumerable) + public SourceLocation(int line, int column) { - var spans = spanEnumerable.ToArray(); - if (spans.Length == 0) + if (line < 1) { - throw new ArgumentException("Cannot merge empty spans", nameof(spanEnumerable)); + throw new ArgumentOutOfRangeException(nameof(line), "Line must be >= 1"); } - var files = spans.Select(s => s.Text).Distinct().ToArray(); - if (files.Length > 1) + if (column < 1) { - throw new ArgumentException("Cannot merge spans from multiple files", nameof(spanEnumerable)); + throw new ArgumentOutOfRangeException(nameof(column), "Column must be >= 1"); } - var first = spans.OrderBy(s => s.Start.Line).ThenBy(s => s.Start.Column).First().Start; - var last = spans.OrderByDescending(s => s.End.Line).ThenByDescending(s => s.End.Column).Last().End; + Line = line; + Column = column; + } - return new SourceSpan(files[0], first, last); + public int Line { get; } + public int Column { get; } + + public int CompareTo(SourceLocation other) + { + var lineComparison = Line.CompareTo(other.Line); + if (lineComparison == 0) + { + return Column.CompareTo(other.Column); + } + + return lineComparison; } public override string ToString() { - if (Start.Line == End.Line) - { - if (Start.Column == End.Column) - { - return $"{Start.Line}:{Start.Column}"; - } + return $"{Line}:{Column}"; + } - return $"{Start.Line}:{Start.Column}-{End.Column}"; - } - - return $"{Start.Line}:{Start.Column}-{End.Line}:{End.Column}"; + public bool Equals(SourceLocation other) + { + return Line == other.Line && Column == other.Column; } public override bool Equals([NotNullWhen(true)] object? obj) { - return obj is SourceSpan other && Equals(other); + return obj is SourceLocation other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Line, Column); + } + + public static bool operator ==(SourceLocation left, SourceLocation right) => left.Equals(right); + public static bool operator !=(SourceLocation left, SourceLocation right) => !left.Equals(right); + public static bool operator <(SourceLocation left, SourceLocation right) => left.CompareTo(right) < 0; + public static bool operator >(SourceLocation left, SourceLocation right) => left.CompareTo(right) > 0; + public static bool operator <=(SourceLocation left, SourceLocation right) => left.CompareTo(right) <= 0; + public static bool operator >=(SourceLocation left, SourceLocation right) => left.CompareTo(right) >= 0; +} + +/// +/// Represents source text with a name (typically filename) and content. +/// Equality is based on both name and content for better semantics. +/// +public struct SourceText : IEquatable +{ + private int _lines = -1; + + public SourceText(string name, string content) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Content = content ?? throw new ArgumentNullException(nameof(content)); + } + + public string Name { get; } + public string Content { get; } + + public int LineCount() + { + if (_lines == -1) + { + _lines = Content.Split('\n').Length + 1; + } + + return _lines; + } + + /// + /// Gets a specific line from the source text (1-based). + /// + public string GetLine(int lineNumber) + { + if (lineNumber < 1) + { + throw new ArgumentOutOfRangeException(nameof(lineNumber)); + } + + var lines = Content.Split('\n'); + return lineNumber <= lines.Length ? lines[lineNumber - 1] : string.Empty; + } + + public bool Equals(SourceText other) + { + return Name == other.Name && Content == other.Content; + } + + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is SourceText other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Name, Content); + } + + public override string ToString() + { + return Name; + } + + public static bool operator ==(SourceText left, SourceText right) => left.Equals(right); + public static bool operator !=(SourceText left, SourceText right) => !left.Equals(right); +} + +/// +/// Represents a span of source code from a start to end location within a source text. +/// +public readonly struct SourceSpan : IEquatable +{ + public SourceSpan(SourceText text, SourceLocation start, SourceLocation end) + { + if (start > end) + { + throw new ArgumentException("Start location cannot be after end location"); + } + + if (end.Line > text.LineCount() || end.Line == text.LineCount() && end.Column > text.GetLine(text.LineCount()).Length + 1) + { + throw new ArgumentException("End location cannot be after text end location"); + } + + Text = text; + Start = start; + End = end; + } + + public SourceText Text { get; } + public SourceLocation Start { get; } + public SourceLocation End { get; } + + /// + /// Gets whether this span represents a single point (start == end). + /// + public bool IsEmpty => Start == End; + + /// + /// Gets whether this span is contained within a single line. + /// + public bool IsSingleLine => Start.Line == End.Line; + + /// + /// Gets the text content covered by this span. + /// + public string GetText() + { + if (IsEmpty) + { + return string.Empty; + } + + var lines = Text.Content.Split('\n'); + + if (IsSingleLine) + { + var line = lines[Start.Line - 1]; + var startCol = Math.Min(Start.Column - 1, line.Length); + var endCol = Math.Min(End.Column - 1, line.Length); + return line.Substring(startCol, Math.Max(0, endCol - startCol)); + } + + var result = new List(); + for (var i = Start.Line - 1; i < Math.Min(End.Line, lines.Length); i++) + { + var line = lines[i]; + if (i == Start.Line - 1) + { + result.Add(line[Math.Min(Start.Column - 1, line.Length)..]); + } + else if (i == End.Line - 1) + { + result.Add(line[..Math.Min(End.Column - 1, line.Length)]); + } + else + { + result.Add(line); + } + } + + return string.Join("\n", result); + } + + /// + /// Merges multiple source spans from the same file into a single span. + /// The result spans from the earliest start to the latest end. + /// + public static SourceSpan Merge(IEnumerable spans) + { + var spanArray = spans.ToArray(); + if (spanArray.Length == 0) + { + throw new ArgumentException("Cannot merge empty collection of spans", nameof(spans)); + } + + var firstText = spanArray[0].Text; + if (spanArray.Any(s => !s.Text.Equals(firstText))) + { + throw new ArgumentException("All spans must be from the same source text", nameof(spans)); + } + + var minStart = spanArray.Min(s => s.Start); + var maxEnd = spanArray.Max(s => s.End); + + return new SourceSpan(firstText, minStart, maxEnd); + } + + public override string ToString() + { + if (IsEmpty) + { + return $"{Start}"; + } + + if (IsSingleLine) + { + return Start.Column == End.Column ? $"{Start}" : $"{Start.Line}:{Start.Column}-{End.Column}"; + } + + return $"{Start}-{End}"; } public bool Equals(SourceSpan other) @@ -58,6 +252,11 @@ public readonly struct SourceSpan(SourceText text, SourceLocation start, SourceL return Text.Equals(other.Text) && Start.Equals(other.Start) && End.Equals(other.End); } + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is SourceSpan other && Equals(other); + } + public override int GetHashCode() { return HashCode.Combine(Text, Start, End); @@ -70,68 +269,6 @@ public readonly struct SourceSpan(SourceText text, SourceLocation start, SourceL public static bool operator !=(SourceSpan left, SourceSpan right) { - return !(left == right); - } -} - -public readonly struct SourceText(string name, string content) : IEquatable -{ - public string Name { get; } = name; - public string Content { get; } = content; - - public bool Equals(SourceText other) - { - return Content == other.Content; - } - - public override bool Equals([NotNullWhen(true)] object? obj) - { - return obj is SourceText other && Equals(other); - } - - public override int GetHashCode() - { - return Content.GetHashCode(); - } - - public static bool operator ==(SourceText left, SourceText right) - { - return left.Equals(right); - } - - public static bool operator !=(SourceText left, SourceText right) - { - return !(left == right); - } -} - -public readonly struct SourceLocation(int line, int column) : IEquatable -{ - public int Line { get; } = line; - public int Column { get; } = column; - - public override bool Equals([NotNullWhen(true)] object? obj) - { - return obj is SourceLocation other && Equals(other); - } - - public bool Equals(SourceLocation other) - { - return Line == other.Line && Column == other.Column; - } - - public override int GetHashCode() - { - return HashCode.Combine(Line, Column); - } - - public static bool operator ==(SourceLocation left, SourceLocation right) - { - return left.Equals(right); - } - - public static bool operator !=(SourceLocation left, SourceLocation right) - { - return !(left == right); + return !left.Equals(right); } } \ No newline at end of file