diff --git a/yaml/_loader_state.ts b/yaml/_loader_state.ts index 2d842efd4afa..b358198cedf0 100644 --- a/yaml/_loader_state.ts +++ b/yaml/_loader_state.ts @@ -39,6 +39,7 @@ import { import { DEFAULT_SCHEMA, type Schema, type TypeMap } from "./_schema.ts"; import type { KindType, Type } from "./_type.ts"; import { isObject, isPlainObject } from "./_utils.ts"; +import { YamlSyntaxError } from "./types.ts"; const CONTEXT_FLOW_IN = 1; const CONTEXT_FLOW_OUT = 2; @@ -64,7 +65,7 @@ export interface LoaderStateOptions { /** compatibility with JSON.parse behaviour. */ allowDuplicateKeys?: boolean; /** function to call on warning messages. */ - onWarning?(error: SyntaxError): void; + onWarning?(error: YamlSyntaxError): void; } const ESCAPED_HEX_LENGTHS = new Map([ @@ -169,18 +170,6 @@ function getSnippet(buffer: string, position: number): string | null { return `${indent + head + snippet + tail}\n${caretIndent}^`; } -function markToString( - buffer: string, - position: number, - line: number, - column: number, -): string { - let where = `at line ${line + 1}, column ${column + 1}`; - const snippet = getSnippet(buffer, position); - if (snippet) where += `:\n${snippet}`; - return where; -} - function getIndentStatus(lineIndent: number, parentIndent: number) { if (lineIndent > parentIndent) return 1; if (lineIndent < parentIndent) return -1; @@ -229,7 +218,7 @@ export class LoaderState { lineIndent = 0; lineStart = 0; line = 0; - onWarning: ((error: SyntaxError) => void) | undefined; + onWarning: ((error: YamlSyntaxError) => void) | undefined; allowDuplicateKeys: boolean; implicitTypes: Type<"scalar">[]; typeMap: TypeMap; @@ -283,14 +272,14 @@ export class LoaderState { } } - #createError(message: string): SyntaxError { - const mark = markToString( - this.#scanner.source, - this.#scanner.position, - this.line, - this.#scanner.position - this.lineStart, - ); - return new SyntaxError(`${message} ${mark}`); + #createError(message: string): YamlSyntaxError { + const offset = this.#scanner.position; + const snippet = getSnippet(this.#scanner.source, offset) ?? undefined; + return new YamlSyntaxError(message, { + line: this.line + 1, + column: offset - this.lineStart + 1, + offset, + }, snippet); } dispatchWarning(message: string) { @@ -1852,9 +1841,19 @@ export class LoaderState { return result; } - *readDocuments() { + *readDocuments( + options: { singleDocument?: boolean } = {}, + ): Generator { + const { singleDocument = false } = options; + let yielded = 0; while (!this.#scanner.eof()) { + if (singleDocument && yielded > 0) { + throw this.#createError( + "Found more than 1 document in the stream: expected a single document", + ); + } yield this.readDocument(); + yielded++; } } } diff --git a/yaml/deno.json b/yaml/deno.json index 4c3e6acfa39b..e5fb7e38cd45 100644 --- a/yaml/deno.json +++ b/yaml/deno.json @@ -5,6 +5,7 @@ ".": "./mod.ts", "./parse": "./parse.ts", "./stringify": "./stringify.ts", + "./types": "./types.ts", "./unstable-stringify": "./unstable_stringify.ts", "./unstable-parse": "./unstable_parse.ts" } diff --git a/yaml/mod.ts b/yaml/mod.ts index 11923155c400..e02a9f85fb38 100644 --- a/yaml/mod.ts +++ b/yaml/mod.ts @@ -52,3 +52,4 @@ export * from "./parse.ts"; export * from "./stringify.ts"; +export * from "./types.ts"; diff --git a/yaml/parse.ts b/yaml/parse.ts index be131d8d2109..f13c1b9c4231 100644 --- a/yaml/parse.ts +++ b/yaml/parse.ts @@ -7,6 +7,7 @@ import { isEOL } from "./_chars.ts"; import { LoaderState } from "./_loader_state.ts"; import { SCHEMA_MAP, type SchemaType } from "./_schema.ts"; +import type { YamlSyntaxError } from "./types.ts"; export type { SchemaType }; @@ -20,16 +21,16 @@ export interface ParseOptions { schema?: SchemaType; /** * If `true`, duplicate keys will overwrite previous values. Otherwise, - * duplicate keys will throw a {@linkcode SyntaxError}. + * duplicate keys will throw a {@linkcode YamlSyntaxError}. * * @default {false} */ allowDuplicateKeys?: boolean; /** * If defined, a function to call on warning messages taking a - * {@linkcode SyntaxError} as its only argument. + * {@linkcode YamlSyntaxError} as its only argument. */ - onWarning?(error: SyntaxError): void; + onWarning?(error: YamlSyntaxError): void; } function sanitizeInput(input: string) { @@ -64,8 +65,8 @@ function sanitizeInput(input: string) { * assertEquals(data, { id: 1, name: "Alice" }); * ``` * - * @throws {SyntaxError} Throws if the YAML is invalid or contains more than - * one document. + * @throws {YamlSyntaxError} Throws if the YAML is invalid or contains more + * than one document. * @param content YAML string to parse. * @param options Parsing options. * @returns Parsed document. @@ -79,13 +80,9 @@ export function parse( ...options, schema: SCHEMA_MAP.get(options.schema!)!, }); - const documentGenerator = state.readDocuments(); - const document = documentGenerator.next().value; - if (!documentGenerator.next().done) { - throw new SyntaxError( - "Found more than 1 document in the stream: expected a single document", - ); - } + const documents = state.readDocuments({ singleDocument: true }); + const document = documents.next().value; + documents.next(); return document ?? null; } @@ -112,7 +109,7 @@ export function parse( * assertEquals(data, [ { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, { id: 3, name: "Eve" }]); * ``` * - * @throws {SyntaxError} Throws if the YAML is invalid. + * @throws {YamlSyntaxError} Throws if the YAML is invalid. * @param content YAML string to parse. * @param options Parsing options. * @returns Array of parsed documents. diff --git a/yaml/parse_test.ts b/yaml/parse_test.ts index ec9a535ae483..c9ecd3da6a4f 100644 --- a/yaml/parse_test.ts +++ b/yaml/parse_test.ts @@ -5,6 +5,7 @@ import { parse, parseAll } from "./parse.ts"; import { type ImplicitType, parse as unstableParse } from "./unstable_parse.ts"; +import { YamlSyntaxError } from "./types.ts"; import { assert, assertEquals, @@ -916,6 +917,31 @@ Deno.test("parse() throws if there are more than one document in the yaml", () = ); }); +Deno.test("parse() throws YamlSyntaxError with structured position info", () => { + const error = assertThrows( + () => parse("foo: bar\n baz: qux"), + YamlSyntaxError, + ); + assertInstanceOf(error, YamlSyntaxError); + assertEquals(typeof error.line, "number"); + assertEquals(typeof error.column, "number"); + assertEquals(typeof error.offset, "number"); + assertEquals(error.line >= 1, true); + assertEquals(error.column >= 1, true); + assertEquals(error.offset >= 0, true); + assertEquals(typeof error.snippet, "string"); +}); + +Deno.test("parse() multi-document error points at the second document marker", () => { + const error = assertThrows( + () => parse("hello: world\n---\nfoo: bar"), + YamlSyntaxError, + ); + assertEquals(error.line, 2); + assertEquals(error.column, 1); + assertEquals(error.offset, 13); +}); + Deno.test("parse() throws when the directive name is empty", () => { assertThrows( () => diff --git a/yaml/types.ts b/yaml/types.ts new file mode 100644 index 000000000000..1ad7328f4c69 --- /dev/null +++ b/yaml/types.ts @@ -0,0 +1,126 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * Type definitions for the YAML parser. + * + * @module + */ + +/** + * Position information for error reporting. + * + * `line` and `column` are 1-indexed; `offset` is the 0-indexed character + * offset into the source string. + */ +export interface YamlPosition { + /** Line number (1-indexed). */ + readonly line: number; + /** Column number (1-indexed). */ + readonly column: number; + /** Character offset in the input (0-indexed). */ + readonly offset: number; +} + +/** + * Error thrown when YAML parsing fails. + * + * Subclass of {@linkcode SyntaxError}, so existing + * `instanceof SyntaxError` checks keep working. Carries structured + * position information so consumers do not have to parse the message. + * + * @example Usage + * ```ts + * import { parse, YamlSyntaxError } from "@std/yaml"; + * import { assertInstanceOf } from "@std/assert"; + * + * try { + * parse(`"`); + * } catch (error) { + * assertInstanceOf(error, YamlSyntaxError); + * assertInstanceOf(error, SyntaxError); + * } + * ``` + */ +export class YamlSyntaxError extends SyntaxError { + /** + * The line number where the error occurred (1-indexed). + * + * @example Usage + * ```ts + * import { YamlSyntaxError } from "@std/yaml"; + * import { assertEquals } from "@std/assert"; + * + * const error = new YamlSyntaxError("Test", { line: 5, column: 10, offset: 50 }); + * assertEquals(error.line, 5); + * ``` + */ + readonly line: number; + /** + * The column number where the error occurred (1-indexed). + * + * @example Usage + * ```ts + * import { YamlSyntaxError } from "@std/yaml"; + * import { assertEquals } from "@std/assert"; + * + * const error = new YamlSyntaxError("Test", { line: 5, column: 10, offset: 50 }); + * assertEquals(error.column, 10); + * ``` + */ + readonly column: number; + /** + * The character offset where the error occurred (0-indexed). + * + * @example Usage + * ```ts + * import { YamlSyntaxError } from "@std/yaml"; + * import { assertEquals } from "@std/assert"; + * + * const error = new YamlSyntaxError("Test", { line: 5, column: 10, offset: 50 }); + * assertEquals(error.offset, 50); + * ``` + */ + readonly offset: number; + /** + * A formatted snippet of the source around the error, including a caret + * line indicating the column. Undefined when the source is empty. + * + * @example Usage + * ```ts + * import { YamlSyntaxError } from "@std/yaml"; + * import { assertEquals } from "@std/assert"; + * + * const snippet = " foo: bar\n ^"; + * const error = new YamlSyntaxError( + * "Test", + * { line: 1, column: 6, offset: 5 }, + * snippet, + * ); + * assertEquals(error.snippet, snippet); + * ``` + */ + readonly snippet?: string; + + /** + * Constructs a new YamlSyntaxError. + * + * The constructed `message` is `${message} at line ${line}, column ${column}`, + * with `:\n${snippet}` appended when a snippet is provided. + * + * @param message The error message describing the syntax issue. + * @param position The position in the YAML source where the error occurred. + * @param snippet Optional formatted snippet of the source around the error. + */ + constructor(message: string, position: YamlPosition, snippet?: string) { + let fullMessage = + `${message} at line ${position.line}, column ${position.column}`; + if (snippet) fullMessage += `:\n${snippet}`; + super(fullMessage); + this.name = "YamlSyntaxError"; + this.line = position.line; + this.column = position.column; + this.offset = position.offset; + if (snippet !== undefined) this.snippet = snippet; + } +} diff --git a/yaml/types_test.ts b/yaml/types_test.ts new file mode 100644 index 000000000000..ec57505609f3 --- /dev/null +++ b/yaml/types_test.ts @@ -0,0 +1,45 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +import { assertEquals, assertInstanceOf } from "@std/assert"; +import { YamlSyntaxError } from "./types.ts"; + +Deno.test("YamlSyntaxError() is a SyntaxError", () => { + const error = new YamlSyntaxError("oops", { line: 1, column: 1, offset: 0 }); + assertInstanceOf(error, SyntaxError); + assertEquals(error.name, "YamlSyntaxError"); +}); + +Deno.test("YamlSyntaxError() exposes structured position info", () => { + const error = new YamlSyntaxError("oops", { + line: 5, + column: 10, + offset: 50, + }); + assertEquals(error.line, 5); + assertEquals(error.column, 10); + assertEquals(error.offset, 50); + assertEquals(error.snippet, undefined); +}); + +Deno.test("YamlSyntaxError() formats message without snippet", () => { + const error = new YamlSyntaxError("oops", { + line: 5, + column: 10, + offset: 50, + }); + assertEquals(error.message, "oops at line 5, column 10"); +}); + +Deno.test("YamlSyntaxError() formats message with snippet", () => { + const snippet = " foo: bar\n ^"; + const error = new YamlSyntaxError( + "oops", + { line: 1, column: 6, offset: 5 }, + snippet, + ); + assertEquals( + error.message, + `oops at line 1, column 6:\n${snippet}`, + ); + assertEquals(error.snippet, snippet); +}); diff --git a/yaml/unstable_parse.ts b/yaml/unstable_parse.ts index 8edf35dcdd9c..856e8268d625 100644 --- a/yaml/unstable_parse.ts +++ b/yaml/unstable_parse.ts @@ -52,8 +52,8 @@ function sanitizeInput(input: string) { * assertEquals(data, { id: 1, name: "Alice" }); * ``` * - * @throws {SyntaxError} Throws if the YAML is invalid or contains more than - * one document. + * @throws {YamlSyntaxError} Throws if the YAML is invalid or contains more + * than one document. * @param content YAML string to parse. * @param options Parsing options. * @returns Parsed document. @@ -67,13 +67,9 @@ export function parse( ...options, schema: getSchema(options.schema, options.extraTypes), }); - const documentGenerator = state.readDocuments(); - const document = documentGenerator.next().value; - if (!documentGenerator.next().done) { - throw new SyntaxError( - "Found more than 1 document in the stream: expected a single document", - ); - } + const documents = state.readDocuments({ singleDocument: true }); + const document = documents.next().value; + documents.next(); return document ?? null; } @@ -100,7 +96,7 @@ export function parse( * assertEquals(data, [ { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, { id: 3, name: "Eve" }]); * ``` * - * @throws {SyntaxError} Throws if the YAML is invalid. + * @throws {YamlSyntaxError} Throws if the YAML is invalid. * @param content YAML string to parse. * @param options Parsing options. * @returns Array of parsed documents.