Skip to content
Open
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
45 changes: 22 additions & 23 deletions yaml/_loader_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<number, number>([
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1852,9 +1841,19 @@ export class LoaderState {
return result;
}

*readDocuments() {
*readDocuments(
options: { singleDocument?: boolean } = {},
): Generator<unknown> {
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++;
}
}
}
1 change: 1 addition & 0 deletions yaml/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
1 change: 1 addition & 0 deletions yaml/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@

export * from "./parse.ts";
export * from "./stringify.ts";
export * from "./types.ts";
23 changes: 10 additions & 13 deletions yaml/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand All @@ -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;
}

Expand All @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions yaml/parse_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
() =>
Expand Down
126 changes: 126 additions & 0 deletions yaml/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
45 changes: 45 additions & 0 deletions yaml/types_test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading