Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<AnalysisMode>Recommended</AnalysisMode>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CS1591;MAAI001</NoWarn>
<Version>1.0.4</Version>
<Version>1.0.5</Version>
<PackageVersion>$(Version)</PackageVersion>
</PropertyGroup>

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,3 +398,18 @@ formatting is not applied. These tools use the same relative-path scope, read ap
cancellation and configured source/range byte budgets as other reads. XLSX files must be present
in the scoped store; text extraction is not required for these native reads.
Generic text reads reject `.xlsx` files and text searches skip them, so the agent cannot accidentally receive ZIP bytes through a text tool.

### Table metadata for CSV and XLSX

`await context.Documents.GetTablesInfoAsync(path, headerRow: null, delimiter: null)`
returns ordered headers and nonempty data-row counts without returning data rows.
The read-only agent tool is `file_context_tables_info`; it follows read approval settings.
XLSX returns each named table (excluding its header and totals), or a worksheet summary
when no named tables exist. Blank and duplicate headers retain column positions.
CSV detects comma, semicolon, tab or pipe, or accepts an explicit single-character delimiter.
Quoted multiline fields form one record; physically empty lines are skipped.
The optional one-based headerRow selects a CSV logical record or worksheet row;
otherwise the first nonempty record/row is the header. Named Excel tables own their headers.
Empty files/sheets return no headers and zero rows. Formula cells count as data without evaluation.
`FileContextTableReader.Read` exposes the same parser for a caller-owned seekable stream.
Source and serialized-result budgets follow the configured full-read and range-read byte budgets.
78 changes: 78 additions & 0 deletions src/ManagedCode.FileContext/Documents/FileContextCsvTables.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Text;
using Microsoft.VisualBasic.FileIO;

namespace ManagedCode.FileContext;

internal static class FileContextCsvTables
{
public static FileContextTablesInfo Read(Stream source, string path, int? headerRow,
string? delimiter, CancellationToken token)
{
delimiter ??= DetectDelimiter(source, headerRow, token);
if (delimiter.Length != 1 || delimiter[0] is '"' or '\r' or '\n' or '\0')
{ throw new ArgumentException("CSV delimiter must be one character other than quote, newline or NUL.", nameof(delimiter)); }
source.Position = 0;
using var parser = Create(source, delimiter);
string[]? headers = null;
var record = 0;
int? foundHeader = null;
long rows = 0;
var width = 0;
while (!parser.EndOfData)
{
token.ThrowIfCancellationRequested();
var fields = parser.ReadFields()!;
record++;
if (headerRow.HasValue && record < headerRow.Value) { continue; }
if (headers is null)
{
if (!headerRow.HasValue && fields.All(string.IsNullOrEmpty)) { continue; }
headers = fields;
foundHeader = record;
width = fields.Length;
continue;
}
width = Math.Max(width, fields.Length);
if (fields.Any(static field => field.Length > 0)) { rows++; }
}
if (headerRow.HasValue && headers is null)
{ throw new ArgumentException("The requested CSV header record does not exist.", nameof(headerRow)); }
var names = Enumerable.Range(0, width).Select(index => index < (headers?.Length ?? 0) ? headers![index] : string.Empty).ToArray();
return new(path, "csv", delimiter, [new(Path.GetFileName(path), null, foundHeader, 1, names, rows)]);
}

private static TextFieldParser Create(Stream source, string delimiter)
{
var parser = new TextFieldParser(source, new UTF8Encoding(false, true), true, true)
{ TextFieldType = FieldType.Delimited, HasFieldsEnclosedInQuotes = true, TrimWhiteSpace = false };
parser.SetDelimiters(delimiter);
return parser;
}

private static string DetectDelimiter(Stream source, int? headerRow, CancellationToken token)
{
var best = ",";
var columns = 0;
foreach (var candidate in new[] { ",", ";", "\t", "|" })
{
token.ThrowIfCancellationRequested();
source.Position = 0;
using var parser = Create(source, candidate);
try
{
var record = 0;
while (!parser.EndOfData)
{
token.ThrowIfCancellationRequested();
var fields = parser.ReadFields()!;
record++;
if (record < (headerRow ?? 1)) { continue; }
if (fields.Length > columns) { best = candidate; columns = fields.Length; }
break;
}
}
catch (MalformedLineException) { /* A different delimiter can make a quoted field invalid. */ }
}
return best;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@ public Task<FileContextWorkbookRange> ReadWorkbookRangeAsync(string path, string
private Task<T> ReadWorkbookAsync<T>(string path, Func<SpreadsheetDocument, CancellationToken, T> read, CancellationToken cancellationToken)
{
RequireExtension(path, ".xlsx");
return FileContextOperation.RunAsync(options.OperationTimeout, async token =>
return ReadSourceAsync(path, (buffer, token) =>
{
using var document = SpreadsheetDocument.Open(buffer, false);
return read(document, token);
}, cancellationToken);
}

private Task<T> ReadSourceAsync<T>(string path, Func<MemoryStream, CancellationToken, T> read, CancellationToken cancellationToken) =>
FileContextOperation.RunAsync(options.OperationTimeout, async token =>
{
var metadata = await store.GetMetadataAsync(path, token).ConfigureAwait(false)
?? throw new FileNotFoundException("The workbook was not found in this file context.", path);
?? throw new FileNotFoundException("The document was not found in this file context.", path);
if (metadata.Length > (ulong)options.MaximumFullReadBytes)
{ throw new IOException("Workbook exceeds the configured source read budget."); }
{ throw new IOException("Document exceeds the configured source read budget."); }
var source = await store.OpenReadAsync(path, token).ConfigureAwait(false);
await using var lifetime = source.ConfigureAwait(false);
using var buffer = new MemoryStream();
Expand All @@ -33,13 +41,11 @@ private Task<T> ReadWorkbookAsync<T>(string path, Func<SpreadsheetDocument, Canc
while ((count = await source.ReadAsync(chunk, token).ConfigureAwait(false)) > 0)
{
if (buffer.Length + count > options.MaximumFullReadBytes)
{ throw new IOException("Workbook exceeds the configured source read budget."); }
{ throw new IOException("Document exceeds the configured source read budget."); }
buffer.Write(chunk, 0, count);
}
buffer.Position = 0;
token.ThrowIfCancellationRequested();
using var document = SpreadsheetDocument.Open(buffer, false);
return read(document, token);
return read(buffer, token);
}, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace ManagedCode.FileContext;

public sealed partial class FileContextDocumentService
{
public Task<FileContextTablesInfo> GetTablesInfoAsync(string path, int? headerRow = null,
string? delimiter = null, CancellationToken cancellationToken = default)
{
FileContextTableReader.Validate(path, headerRow);
return ReadSourceAsync(path,
(buffer, token) => FileContextTableReader.Read(buffer, path, headerRow, delimiter, options, token), cancellationToken);
}
}
5 changes: 5 additions & 0 deletions src/ManagedCode.FileContext/Documents/FileContextTableInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace ManagedCode.FileContext;

/// <summary>Headers preserve order, blanks and duplicates. HeaderRow is a one-based worksheet row or CSV logical record.</summary>
public sealed record FileContextTableInfo(string Name, string? Sheet, int? HeaderRow, int StartColumn,
IReadOnlyList<string> Headers, long RowCount);
40 changes: 40 additions & 0 deletions src/ManagedCode.FileContext/Documents/FileContextTableReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Text;
using System.Text.Json;
using DocumentFormat.OpenXml.Packaging;

namespace ManagedCode.FileContext;

/// <summary>Inspects caller-owned, seekable source streams. The caller retains ownership and authorizes access.</summary>
public static class FileContextTableReader
{
public static FileContextTablesInfo Read(Stream source, string fileName, int? headerRow = null,
string? delimiter = null, FileContextOptions? options = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
Validate(fileName, headerRow);
cancellationToken.ThrowIfCancellationRequested();
options ??= new();
options.Validate();
if (!source.CanSeek) { throw new ArgumentException("Table inspection requires a seekable source stream.", nameof(source)); }
if (source.Length > options.MaximumFullReadBytes) { throw new IOException("Table source exceeds the configured read budget."); }
source.Position = 0;
FileContextTablesInfo result;
if (string.Equals(Path.GetExtension(fileName), ".xlsx", StringComparison.OrdinalIgnoreCase))
{
using var workbook = SpreadsheetDocument.Open(source, false);
result = new(fileName, "xlsx", null, FileContextWorkbookTables.Read(workbook, headerRow, cancellationToken));
}
else { result = FileContextCsvTables.Read(source, fileName, headerRow, delimiter, cancellationToken); }
if (Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(result)) > options.MaximumRangeReadBytes)
{ throw new IOException("Table metadata exceeds the configured output read budget."); }
return result;
}

internal static void Validate(string fileName, int? headerRow)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
if (headerRow is < 1) { throw new ArgumentOutOfRangeException(nameof(headerRow)); }
if (Path.GetExtension(fileName).ToLowerInvariant() is not (".xlsx" or ".csv"))
{ throw new ArgumentException("Table inspection supports .xlsx and .csv files.", nameof(fileName)); }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace ManagedCode.FileContext;

/// <summary>Compact metadata only; row counts exclude headers, totals and wholly empty records.</summary>
public sealed record FileContextTablesInfo(string Path, string Format, string? Delimiter, IReadOnlyList<FileContextTableInfo> Tables);

Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ private static List<FileContextWorkbookCell> ReadCells(WorksheetPart worksheet,
return cells;
}

private static FileContextWorkbookCell ReadCell(Cell cell, string[] strings, string address)
internal static FileContextWorkbookCell ReadCell(Cell cell, string[] strings, string address)
{
var type = cell.DataType?.Value;
var value = cell.CellValue?.Text ?? string.Empty;
Expand All @@ -100,7 +100,7 @@ private static FileContextWorkbookCell ReadCell(Cell cell, string[] strings, str
private static WorkbookPart RequireWorkbook(SpreadsheetDocument document) =>
document.WorkbookPart ?? throw new InvalidDataException("The package has no Excel workbook.");

private static int ReadColumn(string address)
internal static int ReadColumn(string address)
{
var result = 0;
foreach (var character in address.TakeWhile(char.IsAsciiLetter))
Expand Down
118 changes: 118 additions & 0 deletions src/ManagedCode.FileContext/Documents/FileContextWorkbookTables.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System.Globalization;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;

namespace ManagedCode.FileContext;

internal static class FileContextWorkbookTables
{
private const int MaximumRangeReferences = 2;
private const int MaximumRows = 1_048_576;
private const int MaximumColumns = 16_384;

public static IReadOnlyList<FileContextTableInfo> Read(SpreadsheetDocument document, int? headerRow, CancellationToken token)
{
if (headerRow > MaximumRows) { throw new ArgumentOutOfRangeException(nameof(headerRow)); }
var workbook = document.WorkbookPart ?? throw new InvalidDataException("The package has no Excel workbook.");
var strings = workbook.SharedStringTablePart?.SharedStringTable?.Elements<SharedStringItem>()
.Select(static item => string.Concat(item.Descendants<Text>().Select(text => text.Text))).ToArray() ?? [];
var result = new List<FileContextTableInfo>();
foreach (var sheet in workbook.Workbook?.Sheets?.Elements<Sheet>() ?? [])
{
token.ThrowIfCancellationRequested();
if (workbook.GetPartById(sheet.Id!) is not WorksheetPart part) { continue; }
var name = sheet.Name?.Value ?? string.Empty;
var tables = part.TableDefinitionParts.Select(static item => item.Table ?? throw new InvalidDataException("Excel table definition is missing.")).ToArray();
if (tables.Length == 0)
{ result.Add(Inspect(part, strings, new(name, name, headerRow), token)); }
foreach (var table in tables)
{ result.Add(Inspect(part, strings, NamedTable(table, name), token)); }
}
return result;
}

private static TableBounds NamedTable(Table table, string sheet)
{
var range = table.Reference?.Value?.Split(':') ?? throw new InvalidDataException("Excel table has no cell range.");
if (range.Length is < 1 or > MaximumRangeReferences) { throw new InvalidDataException("Excel table range must contain one or two cell references."); }
var startColumn = FileContextWorkbookReader.ReadColumn(range[0]);
var endColumn = FileContextWorkbookReader.ReadColumn(range[^1]);
var first = RowNumber(range[0]);
var last = RowNumber(range[^1]);
FileContextWorkbookReader.ValidateRange(first, last - first + 1, startColumn, endColumn - startColumn + 1);
var hasHeader = (table.HeaderRowCount?.Value ?? 1) > 0;
return new(table.Name?.Value ?? sheet, sheet, hasHeader ? first : null,
startColumn, endColumn, first, last - checked((int)(table.TotalsRowCount?.Value ?? 0)),
table.TableColumns?.Elements<TableColumn>().Select(static column => column.Name?.Value ?? string.Empty).ToArray() ?? []);
}

private static FileContextTableInfo Inspect(WorksheetPart part, string[] strings, TableBounds bounds, CancellationToken token)
{
var accumulator = new TableAccumulator(bounds);
using var reader = OpenXmlReader.Create(part);
var rowNumber = 0;
while (reader.Read())
{
token.ThrowIfCancellationRequested();
if (reader.ElementType != typeof(Row) || !reader.IsStartElement) { continue; }
var row = (Row)reader.LoadCurrentElement()!;
rowNumber = row.RowIndex is null ? rowNumber + 1 : checked((int)row.RowIndex.Value);
if (rowNumber < bounds.FirstRow || rowNumber > bounds.LastRow) { continue; }
var cells = new Dictionary<int, string>();
var hasData = false;
var column = 0;
foreach (var cell in row.Elements<Cell>())
{
token.ThrowIfCancellationRequested();
column = cell.CellReference is null ? column + 1 : FileContextWorkbookReader.ReadColumn(cell.CellReference.Value!);
if (column < bounds.StartColumn || column > bounds.EndColumn) { continue; }
var value = FileContextWorkbookReader.ReadCell(cell, strings, string.Empty);
hasData |= value.Value.Length > 0 || value.Formula is not null;
if (value.Value.Length > 0 || value.Formula is not null || cell.InlineString is not null || cell.CellValue is not null)
{ cells[column] = value.Value; }
}
accumulator.Add(rowNumber, cells, hasData);
}
return accumulator.Result();
}

private static int RowNumber(string address) => int.Parse(
new string(address.Where(char.IsAsciiDigit).ToArray()), CultureInfo.InvariantCulture);

private sealed record TableBounds(string Name, string Sheet, int? HeaderRow,
int StartColumn = 1, int EndColumn = MaximumColumns, int FirstRow = 1, int LastRow = MaximumRows, string[]? Names = null);

private sealed class TableAccumulator(TableBounds bounds)
{
private int? headerRow = bounds.HeaderRow;
private bool foundHeader;
private readonly Dictionary<int, string> headers = [];
private int lastColumn = bounds.Names is null ? bounds.StartColumn - 1 : bounds.EndColumn;
private long rows;

public void Add(int row, Dictionary<int, string> cells, bool hasData)
{
if (headerRow.HasValue && row < headerRow) { return; }
if (bounds.Names is null && !headerRow.HasValue && hasData) { headerRow = row; }
if (row == headerRow)
{
foundHeader = true;
foreach (var (column, value) in cells) { headers[column] = value; }
}
else if ((headerRow.HasValue || bounds.Names is not null) && hasData) { rows++; }
if (cells.Count > 0 && (hasData || row == headerRow)) { lastColumn = Math.Max(lastColumn, cells.Keys.Max()); }
}

public FileContextTableInfo Result()
{
if (bounds.Names is null && bounds.HeaderRow.HasValue && !foundHeader)
{ throw new InvalidDataException("The requested worksheet header row does not exist."); }
var names = Enumerable.Range(bounds.StartColumn, lastColumn - bounds.StartColumn + 1)
.Select(column => bounds.Names is not null
? bounds.Names.ElementAtOrDefault(column - bounds.StartColumn) ?? string.Empty
: headers.GetValueOrDefault(column, string.Empty)).ToArray();
return new(bounds.Name, bounds.Sheet, headerRow, bounds.StartColumn, names, rows);
}
}
}
Loading
Loading