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
15 changes: 7 additions & 8 deletions src/lib/grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ type Node =

export type Token = { text: string; start: number };

/** A span of the source bound to a placeholder (`name`) or matched literally. */
/**
* A span of the line bound to a placeholder (`name`) or matched literally.
* Offsets are relative to the start of the line, so a parsed line can be cached
* and reused wherever in a description the same text turns up.
*/
export type Match = { text: string; start: number; end: number; name: string | null };

export type MatchResult = {
Expand Down Expand Up @@ -236,12 +240,7 @@ const matchAtoms = (
* forgiving — a line being typed is malformed most of the time — so it binds
* what it can and reports where it gave up rather than failing outright.
*/
export const matchTemplate = (
grammar: Grammar,
tokens: Token[],
source: string,
sourceStart: number
): MatchResult => {
export const matchTemplate = (grammar: Grammar, tokens: Token[], source: string): MatchResult => {
// A greedy argument runs to the end of the last token rather than the end of
// the line, so trailing whitespace never lands inside a highlighted field.
const last = tokens[tokens.length - 1];
Expand Down Expand Up @@ -277,7 +276,7 @@ export const matchTemplate = (
const tail = bound.matches[bound.matches.length - 1];
if (tail?.name && grammar.isGreedy(tail.name) && consumed < tokens.length) {
tail.end = lineEnd;
tail.text = source.slice(tail.start - sourceStart, lineEnd - sourceStart);
tail.text = source.slice(tail.start, lineEnd);
consumed = tokens.length;
}

Expand Down
19 changes: 18 additions & 1 deletion src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,27 @@ const inline = (text: string) =>
: match
);

export const markdown = (text: string) =>
const render = (text: string) =>
text
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph) => `<p class="mt-2 first:mt-0">${inline(paragraph)}</p>`)
.join('');

// The panel re-renders every prose block it shows each time the active token
// changes — which, on hover, is as often as the pointer crosses a field. The
// input is drawn from the fixed set of strings in the spec, so rendering each
// one once and keeping it costs a bounded amount of memory.
const rendered = new Map<string, string>();

export const markdown = (text: string) => {
let html = rendered.get(text);

if (html === undefined) {
html = render(text);
rendered.set(text, html);
}

return html;
};
129 changes: 95 additions & 34 deletions src/lib/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,39 @@

import { grammarFor, matchTemplate, type Token } from './grammar';
import { attributeName, lookup } from './spec';
import type { SDPField, SDPLine, SDPLocation, SDPPart } from './types';
import type { Details, SDPField, SDPLine, SDPLocation, SDPOutline, SDPPart } from './types';

const LINE_BREAK = /\r\n|\r|\n/g;
const TOKEN = /\S+/g;

/** Everything about a line that is decided by its own text and nothing else. */
type LineCore = Pick<SDPLine, 'content' | 'type' | 'attribute' | 'details' | 'fields' | 'parts'>;

/**
* Parsing a line is pure in its content, so the same text always yields the same
* core. An edit only ever rewrites the line the caret is on, which means a
* keystroke reparses one line and reuses every other — what stops a long
* description from being rebuilt from scratch between two characters.
*/
const cores = new Map<string, LineCore>();

// Every intermediate state of a line being typed is cached too, so the cache is
// dropped whole once it outgrows any plausible description rather than being
// aged an entry at a time.
const CORE_LIMIT = 8192;

const coreFor = (content: string): LineCore => {
const cached = cores.get(content);
if (cached) return cached;

const core = parseLine(content);

if (cores.size >= CORE_LIMIT) cores.clear();
cores.set(content, core);

return core;
};

/**
* Parses an SDP body into lines that keep their absolute character offsets in
* the source text, so the caret position in the editor can be mapped back onto
Expand All @@ -26,10 +54,10 @@ export const parseSDP = (sdp: string): SDPLine[] => {

LINE_BREAK.lastIndex = 0;
while ((match = LINE_BREAK.exec(sdp)) !== null) {
lines.push(parseLine(sdp.slice(lineStart, match.index), lineStart));
lines.push(place(sdp.slice(lineStart, match.index), lineStart));
lineStart = match.index + match[0].length;
}
lines.push(parseLine(sdp.slice(lineStart), lineStart));
lines.push(place(sdp.slice(lineStart), lineStart));

// Everything after an "m=" line belongs to that media description, and
// attributes read differently there — a media-level "a=mid" names a stream,
Expand All @@ -50,6 +78,38 @@ export const parseSDP = (sdp: string): SDPLine[] => {
return lines;
};

/** Anchors a line's cached core at the offset this document puts it at. */
const place = (content: string, start: number): SDPLine => ({
...coreFor(content),
start,
end: start + content.length,
section: null
});

/**
* The two whole-document facts the editor's chrome needs, gathered in the one
* pass the lines are already being walked in: the run of lines each media
* description spans, for the gutter marker, and how wide the widest line is,
* which is what the overlay reserves room for while most lines go unrendered.
*/
export const outlineOf = (lines: SDPLine[]): SDPOutline => {
const sections = new Map<number, { first: number; count: number }>();
let columns = 0;

lines.forEach((line, i) => {
if (line.content.length > columns) columns = line.content.length;
if (line.section === null) return;

const span = sections.get(line.section);
if (!span) sections.set(line.section, { first: i, count: 1 });
// Blank lines trailing the section say nothing about it, so the marker
// stops at the last line that does.
else if (line.content.trim()) span.count = i - span.first + 1;
});

return { sections, columns };
};

const tokenize = (value: string, valueStart: number): Token[] => {
const tokens: Token[] = [];
let token: RegExpExecArray | null;
Expand All @@ -62,15 +122,8 @@ const tokenize = (value: string, valueStart: number): Token[] => {
return tokens;
};

const parseLine = (content: string, start: number): SDPLine => {
const line: SDPLine = {
content,
start,
end: start + content.length,
fields: [],
parts: [],
section: null
};
const parseLine = (content: string): LineCore => {
const line: LineCore = { content, fields: [], parts: [] };

// An SDP line is "<single-character type>=<value>"; anything else is free text.
const indent = content.length - content.trimStart().length;
Expand All @@ -81,28 +134,27 @@ const parseLine = (content: string, start: number): SDPLine => {

line.type = content[indent];

const valueStart = start + indent + 2;
const value = content.slice(indent + 2);
const tokens = tokenize(value, valueStart);
const valueStart = indent + 2;
const tokens = tokenize(content.slice(valueStart), valueStart);

if (line.type === 'a' && tokens.length) line.attribute = attributeName(tokens[0].text);

line.details = lookup(line.type, tokens[0]?.text ?? '');
line.fields = bindFields(line, tokens, content, start);
line.fields = bindFields(line.details, tokens, content);
line.parts = buildParts(line, indent);

return line;
};

/** Runs the line's grammar over its tokens and resolves each match to an argument. */
const bindFields = (line: SDPLine, tokens: Token[], content: string, start: number): SDPField[] => {
const bindFields = (details: Details | undefined, tokens: Token[], content: string): SDPField[] => {
const fields: SDPField[] = [];

let consumed = 0;

if (line.details) {
const grammar = grammarFor(line.details);
const result = matchTemplate(grammar, tokens, content, start);
if (details) {
const grammar = grammarFor(details);
const result = matchTemplate(grammar, tokens, content);

consumed = result.consumed;

Expand Down Expand Up @@ -139,42 +191,51 @@ const bindFields = (line: SDPLine, tokens: Token[], content: string, start: numb
* fields — ends up in exactly one part. That keeps the rendered overlay
* glyph-for-glyph aligned with the textarea underneath.
*/
const buildParts = (line: SDPLine, indent: number): SDPPart[] => {
const { content, start, type } = line;
const buildParts = (line: LineCore, indent: number): SDPPart[] => {
const { content, type } = line;
const parts: SDPPart[] = [];

if (indent) parts.push({ text: content.slice(0, indent), kind: 'plain' });
parts.push({ text: type as string, kind: 'type' });

let cursor = start + indent + 1;
let cursor = indent + 1;

line.fields.forEach((field, fieldIndex) => {
if (field.start > cursor) {
parts.push({ text: content.slice(cursor - start, field.start - start), kind: 'plain' });
parts.push({ text: content.slice(cursor, field.start), kind: 'plain' });
}

parts.push({ text: field.text, kind: field.kind, fieldIndex });
cursor = field.end;
});

if (cursor < line.end) parts.push({ text: content.slice(cursor - start), kind: 'plain' });
if (cursor < content.length) parts.push({ text: content.slice(cursor), kind: 'plain' });

return parts;
};

/** Maps a caret offset in the source text onto the line and field it sits in. */
export const locate = (lines: SDPLine[], pos: number): SDPLocation | null => {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (pos > line.end) continue;
if (!lines.length) return null;

for (let j = 0; j < line.fields.length; j++) {
const field = line.fields[j];
if (pos >= field.start && pos <= field.end) return { lineIndex: i, fieldIndex: j };
}
// Lines are in order and cover the text end to end, so the one holding an
// offset is a binary search rather than a walk down from the top.
let low = 0;
let high = lines.length - 1;

while (low < high) {
const mid = (low + high) >> 1;
if (pos > lines[mid].end) low = mid + 1;
else high = mid;
}

const line = lines[low];
const offset = pos - line.start;

return { lineIndex: i, fieldIndex: null };
for (let j = 0; j < line.fields.length; j++) {
const field = line.fields[j];
if (offset >= field.start && offset <= field.end) return { lineIndex: low, fieldIndex: j };
}

return lines.length ? { lineIndex: lines.length - 1, fieldIndex: null } : null;
return { lineIndex: low, fieldIndex: null };
};
21 changes: 18 additions & 3 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,17 @@ export type Details = {
level?: Level;
};

/** One matched span of a line: either a grammar literal or a bound argument. */
/**
* One matched span of a line: either a grammar literal or a bound argument.
* Offsets are relative to the line, not to the description, so the same line
* text parses to the same fields wherever it appears — which is what lets the
* parser cache a line and reuse it across edits.
*/
export type SDPField = {
text: string;
/** Absolute offset of the first character in the source text. */
/** Offset of the first character, from the start of the line. */
start: number;
/** Absolute offset just past the last character in the source text. */
/** Offset just past the last character, from the start of the line. */
end: number;
/** Index into the owning `Details.args`, or null for literals and unmatched tokens. */
argIndex: number | null;
Expand All @@ -55,7 +60,9 @@ export type SDPPart = {

export type SDPLine = {
content: string;
/** Absolute offset of the line's first character in the source text. */
start: number;
/** Absolute offset just past the line's last character in the source text. */
end: number;
/** The single character before the "=", when the line has the shape "x=value". */
type?: string;
Expand All @@ -72,3 +79,11 @@ export type SDPLine = {

/** A caret or pointer position resolved onto a line and, when inside one, a field. */
export type SDPLocation = { lineIndex: number; fieldIndex: number | null };

/** Whole-description measurements the editor's chrome is laid out from. */
export type SDPOutline = {
/** Media section index → the run of lines it spans, for the gutter marker. */
sections: Map<number, { first: number; count: number }>;
/** Length of the longest line, which sets how far the editor scrolls sideways. */
columns: number;
};
Loading
Loading