Skip to content

Diagnostics and UTF-8 source spans

Prerequisites: Core concepts.

interface MdiDiagnostic {
severity: "warning" | "error";
code: string;
message: string;
span?: MdiSourceSpan; // { startByte: number; endByte: number }
}

Diagnostics live in the diagnostics array of a parse result — they are always data, never a thrown exception. Most MDI documents parse with an empty diagnostics array; malformed or ambiguous MDI notation is handled by each construct’s own literal-fallback rule (documented on the syntax reference page for every construct) rather than by raising a diagnostic. This mirrors how Markdown itself treats unrecognized syntax: keep it as text, don’t fail the whole document.

There is currently exactly one diagnostic code implemented in mdi-core:

  • Severity: warning
  • When it fires: front matter declares an mdi: version string that is greater than the crate’s own MDI_SPEC_VERSION (“2.0” today).
  • Span: the entire front-matter block.
  • Message shape: "MDI {declared} is newer than the supported {MDI_SPEC_VERSION}".
---
mdi: "2.1"
---
本文。
[{ "severity": "warning", "code": "mdi.version.unsupported", "message": "MDI 2.1 is newer than the supported 2.0", "span": { "startByte": 0, "endByte": 15 } }]

Per SYNTAX.md, encountering a newer-than-supported version is a SHOULD warn and continue, never a MUST reject — parsing proceeds on a best-effort basis using the rules the parser knows, and the rest of the document still produces a normal tree alongside this one diagnostic.

:::caution[Current implementation status] The comparison mdi-core uses today is a plain string comparison (declared > MDI_SPEC_VERSION), not a semantic version comparison. This is correct for every realistic case while MDI is at a single digit major.minor, but note it is not semver-aware in general — e.g. "2.10" would compare as lexicographically less than "2.9". This is a known limitation of the current implementation, not a documented part of the spec’s version-comparison rule. :::

That’s the complete list — if you’re looking for a diagnostic for, say, invalid kerning amounts or mismatched split-ruby segments, there isn’t one: those cases are silently handled by literal fallback (see the relevant section of the syntax reference), by design, not as a gap.

  • Unit: UTF-8 bytes of the exact source string passed to parse().
  • Range: half-open — startByte inclusive, endByte exclusive. endByte - startByte is the byte length of the spanned text.
  • Not Unicode code points, not UTF-16 code units (what JavaScript string indices are), not grapheme clusters (what a cursor position in a text editor usually is).

Converting a byte span to a JavaScript string index

Section titled “Converting a byte span to a JavaScript string index”
function byteSpanToUtf16Index(source, byteOffset) {
const bytes = new TextEncoder().encode(source);
const prefix = new TextDecoder().decode(bytes.subarray(0, byteOffset));
return prefix.length; // UTF-16 code unit index, usable as a JS string index
}

This round-trip (encode the whole string once, decode a byte-prefix, take its length) is the simplest correct approach; don’t try to approximate it by counting characters, since any codepoint outside the Basic Multilingual Plane (many emoji, some kanji) is more than one UTF-16 unit and more than one byte, in different ratios.