chore(coding-agent): format eval display helpers
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
- Added a per-spawn `effort` parameter to the `task` tool (`"lo"` | `"med"` | `"hi"`): each selector maps onto the resolved model's supported thinking range (lowest, middle, and highest level — whatever the model tops out at, e.g. `high`, `xhigh`, or `max`) and overrides the agent's default selector, including `auto`. Omitting `effort` keeps the existing automatic per-prompt thinking classification.
|
||||
- Added `searxng.engines` setting for the SearXNG web search provider: a comma-separated list of engine names or bang shortcuts (e.g. `ddg, br, startpage`) sent as the API's `engines=` parameter. Shortcuts are resolved to canonical engine names via the instance's `/config` endpoint (cached per endpoint; entries pass through verbatim if `/config` is unreachable). Bang syntax in queries (`!ddg foo`) continues to pass through to the instance, and external bangs (`!!g`) are now stripped client-side since SearXNG answers them with an HTTP redirect even for JSON requests.
|
||||
- Web search queries now understand Google-style directives on every provider: `site:`/`-site:` (plus `domain:`/`host:` aliases), `after:`/`before:`/`since:`/`until:` date bounds, `inurl:`/`intitle:`/`intext:`/`allin*:`, `filetype:`/`ext:`, `lang:`, quoted phrases (including smart quotes), `+term`, `-`/`NOT` exclusions, and `OR`/`|` groups. A shared parser (`web/search/query.ts`) structures the query once per request; each provider maps constraints onto native API filters where the upstream supports them (Perplexity domain/date/language filters on both the API-key and ask paths, Tavily `include_domains`/`exclude_domains` + `start_date`/`end_date`, Exa domain lists + published-date bounds on API and MCP paths, Anthropic `allowed_domains`/`blocked_domains`, xAI `filters.allowed_domains`/`excluded_domains`, Parallel `source_policy`, Brave absolute `freshness` ranges, Firecrawl `tbs=cdr` date ranges, Jina `X-Site`, SearXNG `language`) and otherwise re-emits only the operator syntax its engine parses (full Google syntax for Gemini grounding, OpenAI, Kagi, and the credential-free scrapers — with scraper-hostile path-`site:`/`inurl:` operators demoted to plain keywords; conservative subsets for DuckDuckGo, Mojeek, Kimi, Z.AI, TinyFish, and Synthetic). The pipeline then applies a lenient post-filter to every response: constraints the engine ignored are enforced on the returned sources, and any constraint dimension that would eliminate every result is relaxed and reported to the model (`Note: no results matched \`site:...\`; the constraint was relaxed`) instead of returning nothing. Directive-free queries are passed through byte-identical everywhere.
|
||||
- Eval and browser `run` code previews are now prettified for display: minified/compact agent-written cells are expanded by conservative streaming formatters (Python, JavaScript, Ruby, Julia) that indent block structure, split statements, normalize operator spacing (JS), and expand object literals wider than 100 columns onto one property per line — purely for rendering, the executed source is untouched. The formatters handle in-flight prefixes (unterminated strings/comments/blocks) without inventing closers, and layout decisions are prefix-stable so already-rendered lines never reflow while a call streams.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -207,25 +207,423 @@ function scanTemplate(source: string, start: number): number {
|
||||
}
|
||||
|
||||
function canJoinCloseWithWord(word: string, atSourceEnd: boolean): boolean {
|
||||
return CLOSE_CONTINUATIONS.some(
|
||||
(keyword) => keyword === word || (atSourceEnd && keyword.startsWith(word)),
|
||||
);
|
||||
return CLOSE_CONTINUATIONS.some(keyword => keyword === word || (atSourceEnd && keyword.startsWith(word)));
|
||||
}
|
||||
|
||||
function canAttachToClose(char: string): boolean {
|
||||
return "();,.)]:?+-*/%&|^<>=!".includes(char);
|
||||
}
|
||||
|
||||
const JS_DISPLAY_MAX_LINE_WIDTH = 100;
|
||||
const JS_DISPLAY_INDENT = " ";
|
||||
const BLOCK_BRACE_WORDS: Record<string, true> = {
|
||||
catch: true,
|
||||
class: true,
|
||||
do: true,
|
||||
else: true,
|
||||
finally: true,
|
||||
for: true,
|
||||
function: true,
|
||||
if: true,
|
||||
switch: true,
|
||||
try: true,
|
||||
while: true,
|
||||
with: true,
|
||||
};
|
||||
const OBJECT_PREFIX_WORDS: Record<string, true> = {
|
||||
const: true,
|
||||
default: true,
|
||||
let: true,
|
||||
return: true,
|
||||
throw: true,
|
||||
var: true,
|
||||
yield: true,
|
||||
};
|
||||
|
||||
interface DisplayBraceNode {
|
||||
start: number;
|
||||
end?: number;
|
||||
object: boolean;
|
||||
children: DisplayBraceNode[];
|
||||
}
|
||||
|
||||
function classifySourceBraces(source: string): boolean[] {
|
||||
const objects: boolean[] = [];
|
||||
const statementWords: string[] = [];
|
||||
let previousToken = "";
|
||||
let previousWord = "";
|
||||
let regexAllowed = true;
|
||||
|
||||
for (let index = 0; index < source.length; ) {
|
||||
const char = source[index];
|
||||
const next = source[index + 1];
|
||||
if (/\s/.test(char)) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "/") {
|
||||
index = scanLineComment(source, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
index = scanBlockComment(source, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
index = scanQuoted(source, index);
|
||||
regexAllowed = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "`") {
|
||||
index = scanTemplate(source, index);
|
||||
regexAllowed = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && regexAllowed) {
|
||||
index = scanRegex(source, index);
|
||||
regexAllowed = false;
|
||||
continue;
|
||||
}
|
||||
if (isIdentifierStart(char)) {
|
||||
const end = scanIdentifier(source, index);
|
||||
const word = source.slice(index, end);
|
||||
statementWords.push(word);
|
||||
previousWord = word;
|
||||
previousToken = word;
|
||||
regexAllowed = REGEX_PREFIX_WORDS[word] === true;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char >= "0" && char <= "9") {
|
||||
index = scanNumber(source, index);
|
||||
previousToken = "value";
|
||||
previousWord = "";
|
||||
regexAllowed = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
const isBlock =
|
||||
previousToken === "=>" ||
|
||||
previousToken === ")" ||
|
||||
previousToken === "}" ||
|
||||
BLOCK_BRACE_WORDS[previousWord] === true ||
|
||||
statementWords.some(word => word === "class" || word === "function" || word === "switch");
|
||||
const isObject =
|
||||
!isBlock &&
|
||||
(previousToken === "=" ||
|
||||
previousToken === ":" ||
|
||||
previousToken === "," ||
|
||||
previousToken === "(" ||
|
||||
previousToken === "[" ||
|
||||
previousToken === ";" ||
|
||||
previousToken === "" ||
|
||||
OBJECT_PREFIX_WORDS[previousWord] === true);
|
||||
objects.push(isObject);
|
||||
if (isBlock) statementWords.length = 0;
|
||||
previousToken = "{";
|
||||
previousWord = "";
|
||||
regexAllowed = true;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === "}") {
|
||||
previousToken = "}";
|
||||
previousWord = "";
|
||||
regexAllowed = false;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === ";") {
|
||||
statementWords.length = 0;
|
||||
previousToken = ";";
|
||||
previousWord = "";
|
||||
regexAllowed = true;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === ")") {
|
||||
previousToken = ")";
|
||||
previousWord = "";
|
||||
regexAllowed = false;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === "]" || char === ".") {
|
||||
previousToken = char;
|
||||
previousWord = "";
|
||||
regexAllowed = false;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === "=" && next === ">") {
|
||||
previousToken = "=>";
|
||||
previousWord = "";
|
||||
regexAllowed = true;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
previousToken = char;
|
||||
previousWord = "";
|
||||
regexAllowed = char !== ")" && char !== "]" && char !== ".";
|
||||
index++;
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
function collectDisplayBraceNodes(text: string, objectFlags: readonly boolean[]): DisplayBraceNode[] {
|
||||
const roots: DisplayBraceNode[] = [];
|
||||
const stack: DisplayBraceNode[] = [];
|
||||
let openingIndex = 0;
|
||||
|
||||
for (let index = 0; index < text.length; ) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
if (char === "/" && next === "/") {
|
||||
index = scanLineComment(text, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
index = scanBlockComment(text, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
index = scanQuoted(text, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "`") {
|
||||
index = scanTemplate(text, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
const node: DisplayBraceNode = {
|
||||
start: index,
|
||||
object: objectFlags[openingIndex] === true,
|
||||
children: [],
|
||||
};
|
||||
openingIndex++;
|
||||
const parent = stack[stack.length - 1];
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
stack.push(node);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === "}") {
|
||||
const node = stack.pop();
|
||||
if (node) node.end = index;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function collapseDisplayWhitespace(text: string): string | undefined {
|
||||
const output: string[] = [];
|
||||
let pendingSpace = false;
|
||||
for (let index = 0; index < text.length; ) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
if (char === "\n" || char === "\r" || /\s/.test(char)) {
|
||||
pendingSpace = true;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "/") return undefined;
|
||||
if (char === "/" && next === "*") {
|
||||
const end = scanBlockComment(text, index);
|
||||
if (pendingSpace && output.length > 0) output.push(" ");
|
||||
output.push(text.slice(index, end));
|
||||
pendingSpace = false;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"' || char === "`") {
|
||||
const end = char === "`" ? scanTemplate(text, index) : scanQuoted(text, index);
|
||||
const literal = text.slice(index, end);
|
||||
if (literal.includes("\n") || literal.includes("\r")) return undefined;
|
||||
if (pendingSpace && output.length > 0) output.push(" ");
|
||||
output.push(literal);
|
||||
pendingSpace = false;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (pendingSpace && output.length > 0) output.push(" ");
|
||||
pendingSpace = false;
|
||||
output.push(char);
|
||||
index++;
|
||||
}
|
||||
return output.join("").trim();
|
||||
}
|
||||
|
||||
function splitDisplayObjectProperties(text: string): string[] {
|
||||
const properties: string[] = [];
|
||||
let start = 0;
|
||||
let parenDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
let braceDepth = 0;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index];
|
||||
if (char === "(") parenDepth++;
|
||||
else if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
|
||||
else if (char === "[") bracketDepth++;
|
||||
else if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
|
||||
else if (char === "{") braceDepth++;
|
||||
else if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
|
||||
else if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
|
||||
properties.push(text.slice(start, index + 1));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
properties.push(text.slice(start));
|
||||
return properties;
|
||||
}
|
||||
|
||||
function formatDisplayObjectLiterals(formatted: string, source: string): string {
|
||||
const roots = collectDisplayBraceNodes(formatted, classifySourceBraces(source));
|
||||
|
||||
function renderRange(
|
||||
start: number,
|
||||
end: number,
|
||||
children: readonly DisplayBraceNode[],
|
||||
childIndent: string,
|
||||
): string {
|
||||
const output: string[] = [];
|
||||
let cursor = start;
|
||||
for (const child of children) {
|
||||
if (child.start < start || child.start >= end) continue;
|
||||
output.push(formatted.slice(cursor, child.start));
|
||||
output.push(renderNode(child, childIndent));
|
||||
cursor = child.end === undefined ? end : child.end + 1;
|
||||
}
|
||||
output.push(formatted.slice(cursor, end));
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
function renderNode(node: DisplayBraceNode, parentIndent: string): string {
|
||||
const lineStart = Math.max(0, formatted.lastIndexOf("\n", node.start - 1) + 1);
|
||||
const linePrefix = formatted.slice(lineStart, node.start);
|
||||
const ownIndent = /^[ \t]*$/.test(linePrefix) ? linePrefix : parentIndent;
|
||||
const propertyIndent = ownIndent + JS_DISPLAY_INDENT;
|
||||
const closed = node.end !== undefined;
|
||||
const end = node.end ?? formatted.length;
|
||||
if (!node.object) {
|
||||
return `{${renderRange(node.start + 1, end, node.children, propertyIndent)}${closed ? "}" : ""}`;
|
||||
}
|
||||
|
||||
// Walk the body left to right and decide the layout at the FIRST decisive
|
||||
// event, so the decision is a pure function of the source prefix and never
|
||||
// flips as more code streams in:
|
||||
// - a raw newline or a multi-line block child commits lines with the
|
||||
// original inline layout -> verbatim forever;
|
||||
// - a nested object exploding, or the flat width passing the cap,
|
||||
// explodes this object (and, transitively, every enclosing inline
|
||||
// object in this same render pass).
|
||||
let mode: "inline" | "verbatim" | "explode" = "inline";
|
||||
let width = node.start - lineStart + 4;
|
||||
const pieces: string[] = [];
|
||||
let cursor = node.start + 1;
|
||||
const consume = (text: string, blockLike: boolean) => {
|
||||
pieces.push(text);
|
||||
if (mode !== "inline" || text.length === 0) return;
|
||||
if (text.includes("\n") || text.includes("\r")) {
|
||||
mode = blockLike ? "verbatim" : "explode";
|
||||
return;
|
||||
}
|
||||
width += text.length;
|
||||
if (width > JS_DISPLAY_MAX_LINE_WIDTH) mode = "explode";
|
||||
};
|
||||
for (const child of node.children) {
|
||||
if (child.start < node.start + 1 || child.start >= end) continue;
|
||||
consume(formatted.slice(cursor, child.start), true);
|
||||
consume(renderNode(child, propertyIndent), !child.object);
|
||||
cursor = child.end === undefined ? end : child.end + 1;
|
||||
}
|
||||
consume(formatted.slice(cursor, end), true);
|
||||
const body = pieces.join("");
|
||||
|
||||
if (mode === "inline") {
|
||||
const flat = collapseDisplayWhitespace(body);
|
||||
if (flat === undefined) mode = "verbatim";
|
||||
else if (!closed) return flat.length > 0 ? `{ ${flat}` : "{";
|
||||
else return flat.length > 0 ? `{ ${flat} }` : "{}";
|
||||
}
|
||||
if (mode === "verbatim") {
|
||||
return `{${body}${closed ? "}" : ""}`;
|
||||
}
|
||||
|
||||
const properties = splitDisplayObjectProperties(body)
|
||||
.map(property => property.trim())
|
||||
.filter(property => property.length > 0);
|
||||
if (properties.length === 0) return closed ? "{}" : "{";
|
||||
const lines = properties.map(property => `${propertyIndent}${property}`);
|
||||
return `{\n${lines.join("\n")}${closed ? `\n${ownIndent}}` : ""}`;
|
||||
}
|
||||
|
||||
return renderRange(0, formatted.length, roots, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next operator token eligible for spacing normalization. Angle
|
||||
* brackets and bare `*` are intentionally excluded: generics (`Map<K, V>`) and
|
||||
* generators (`function*`) would be mangled by binary-operator spacing.
|
||||
*/
|
||||
function scanDisplayOperator(source: string, start: number): string | undefined {
|
||||
const three = source.slice(start, start + 3);
|
||||
if (three === "===" || three === "!==" || three === "**=" || three === "&&=" || three === "||=" || three === "??=") {
|
||||
return three;
|
||||
}
|
||||
const two = source.slice(start, start + 2);
|
||||
if (
|
||||
two === "=>" ||
|
||||
two === "==" ||
|
||||
two === "!=" ||
|
||||
two === "&&" ||
|
||||
two === "||" ||
|
||||
two === "??" ||
|
||||
two === "++" ||
|
||||
two === "--" ||
|
||||
two === "+=" ||
|
||||
two === "-=" ||
|
||||
two === "*=" ||
|
||||
two === "/=" ||
|
||||
two === "%=" ||
|
||||
two === "&=" ||
|
||||
two === "|=" ||
|
||||
two === "^=" ||
|
||||
two === "**"
|
||||
) {
|
||||
return two;
|
||||
}
|
||||
return "=+-/%&|^!?:,".includes(source[start]) ? source[start] : undefined;
|
||||
}
|
||||
|
||||
function operatorSpacing(token: string, unary: boolean, ternaryPending: boolean): { before: boolean; after: boolean } {
|
||||
if (token === ",") return { before: false, after: true };
|
||||
if (token === ":") return { before: ternaryPending, after: true };
|
||||
if (token === "?") return { before: true, after: true };
|
||||
if (token === "!" || unary || token === "++" || token === "--") {
|
||||
return { before: false, after: false };
|
||||
}
|
||||
return { before: true, after: true };
|
||||
}
|
||||
|
||||
/** Formats JavaScript/TypeScript-like eval source for safe, stable display without requiring valid syntax. */
|
||||
export function formatJavaScriptForDisplay(source: string): string {
|
||||
const output: string[] = [];
|
||||
const parens: ParenFrame[] = [];
|
||||
const sourceObjectBraces = classifySourceBraces(source);
|
||||
const braceKinds: Array<"object" | "block"> = [];
|
||||
let sourceBraceIndex = 0;
|
||||
let index = 0;
|
||||
let indent = 0;
|
||||
let atLineStart = true;
|
||||
let lastChar = "";
|
||||
let pendingWhitespace = "";
|
||||
let pendingBreak: PendingBreak | undefined;
|
||||
let pendingOperatorSpace = false;
|
||||
let ternaryPending = false;
|
||||
let afterForSemicolon = false;
|
||||
let regexAllowed = true;
|
||||
let pendingFor = false;
|
||||
@@ -252,23 +650,59 @@ export function formatJavaScriptForDisplay(source: string): string {
|
||||
return width;
|
||||
}
|
||||
|
||||
function trimTrailingHorizontalWhitespace(): void {
|
||||
for (let index = output.length - 1; index >= 0; index--) {
|
||||
const chunk = output[index];
|
||||
const trimmed = chunk.replace(/[ \t]+$/, "");
|
||||
if (trimmed !== chunk) {
|
||||
if (trimmed.length > 0) output[index] = trimmed;
|
||||
else output.splice(index, 1);
|
||||
}
|
||||
if (trimmed.length > 0 || chunk.includes("\n") || chunk.includes("\r")) break;
|
||||
}
|
||||
for (let index = output.length - 1; index >= 0; index--) {
|
||||
const chunk = output[index];
|
||||
if (chunk.length > 0) {
|
||||
lastChar = chunk[chunk.length - 1];
|
||||
return;
|
||||
}
|
||||
}
|
||||
lastChar = "";
|
||||
}
|
||||
|
||||
function flushWhitespace(): void {
|
||||
if (atLineStart) {
|
||||
const width = Math.max(indent * 4, whitespaceWidth(pendingWhitespace));
|
||||
if (width > 0) append(" ".repeat(width));
|
||||
} else {
|
||||
append(pendingWhitespace);
|
||||
} else if (pendingWhitespace.length > 0) {
|
||||
append(" ");
|
||||
}
|
||||
pendingWhitespace = "";
|
||||
}
|
||||
|
||||
function flushOperatorSpace(nextText: string): void {
|
||||
if (!pendingOperatorSpace) return;
|
||||
if (!atLineStart && lastChar !== " " && !")]},.;".includes(nextText[0] ?? "")) append(" ");
|
||||
pendingWhitespace = "";
|
||||
pendingOperatorSpace = false;
|
||||
}
|
||||
|
||||
function appendOperator(token: string, before: boolean, after: boolean): void {
|
||||
trimTrailingHorizontalWhitespace();
|
||||
if (before && !atLineStart && lastChar !== " " && lastChar !== "\n") append(" ");
|
||||
append(token);
|
||||
pendingOperatorSpace = after;
|
||||
}
|
||||
|
||||
function forceBreak(): void {
|
||||
pendingWhitespace = "";
|
||||
pendingOperatorSpace = false;
|
||||
if (!atLineStart) newline();
|
||||
pendingBreak = undefined;
|
||||
}
|
||||
|
||||
function prepareToken(kind: "word" | "punctuation" | "value", text: string, end: number): void {
|
||||
flushOperatorSpace(text);
|
||||
if (pendingBreak === "close") {
|
||||
if (kind === "word" && canJoinCloseWithWord(text, end === source.length)) {
|
||||
pendingWhitespace = "";
|
||||
@@ -310,6 +744,7 @@ export function formatJavaScriptForDisplay(source: string): string {
|
||||
|
||||
if (char === "\n" || char === "\r") {
|
||||
pendingWhitespace = "";
|
||||
pendingOperatorSpace = false;
|
||||
pendingBreak = undefined;
|
||||
afterForSemicolon = false;
|
||||
newline();
|
||||
@@ -393,13 +828,18 @@ export function formatJavaScriptForDisplay(source: string): string {
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
const objectBrace = sourceObjectBraces[sourceBraceIndex] === true;
|
||||
sourceBraceIndex++;
|
||||
braceKinds.push(objectBrace ? "object" : "block");
|
||||
prepareToken("punctuation", char, index + 1);
|
||||
const hadWhitespace = pendingWhitespace.length > 0;
|
||||
flushWhitespace();
|
||||
if (!atLineStart && !hadWhitespace && !" ([{".includes(lastChar)) append(" ");
|
||||
append(char);
|
||||
indent++;
|
||||
pendingBreak = "brace";
|
||||
if (!objectBrace) {
|
||||
indent++;
|
||||
pendingBreak = "brace";
|
||||
}
|
||||
regexAllowed = true;
|
||||
pendingFor = false;
|
||||
lastTokenWasWord = false;
|
||||
@@ -407,13 +847,26 @@ export function formatJavaScriptForDisplay(source: string): string {
|
||||
continue;
|
||||
}
|
||||
if (char === "}") {
|
||||
const braceKind = braceKinds.pop() ?? "block";
|
||||
prepareToken("punctuation", char, index + 1);
|
||||
pendingWhitespace = "";
|
||||
if (!atLineStart) newline();
|
||||
indent = Math.max(0, indent - 1);
|
||||
flushWhitespace();
|
||||
append(char);
|
||||
pendingBreak = "close";
|
||||
if (braceKind === "object") {
|
||||
// Multi-line object closers keep their line indentation; inline
|
||||
// closers attach tight so the post-pass controls the spacing.
|
||||
if (atLineStart) flushWhitespace();
|
||||
else {
|
||||
pendingWhitespace = "";
|
||||
trimTrailingHorizontalWhitespace();
|
||||
}
|
||||
append(char);
|
||||
pendingBreak = undefined;
|
||||
} else {
|
||||
pendingWhitespace = "";
|
||||
if (!atLineStart) newline();
|
||||
indent = Math.max(0, indent - 1);
|
||||
flushWhitespace();
|
||||
append(char);
|
||||
pendingBreak = "close";
|
||||
}
|
||||
regexAllowed = false;
|
||||
pendingFor = false;
|
||||
lastTokenWasWord = false;
|
||||
@@ -460,6 +913,24 @@ export function formatJavaScriptForDisplay(source: string): string {
|
||||
continue;
|
||||
}
|
||||
|
||||
const operator = scanDisplayOperator(source, index);
|
||||
if (operator && !(operator === "?" && (next === "." || next === ":"))) {
|
||||
// In a regex-eligible position the previous token was an operator or
|
||||
// keyword, so `+`/`-` here are unary signs, not binary operators.
|
||||
const unary = (operator === "+" || operator === "-") && regexAllowed;
|
||||
prepareToken("punctuation", operator, index + operator.length);
|
||||
flushWhitespace();
|
||||
const spacing = operatorSpacing(operator, unary, ternaryPending);
|
||||
appendOperator(operator, spacing.before, spacing.after);
|
||||
if (operator === "?") ternaryPending = true;
|
||||
else if (operator === ":") ternaryPending = false;
|
||||
regexAllowed = operator !== "++" && operator !== "--";
|
||||
pendingFor = false;
|
||||
lastTokenWasWord = false;
|
||||
index += operator.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const doubledPostfix = (char === "+" && next === "+") || (char === "-" && next === "-");
|
||||
const token = doubledPostfix ? source.slice(index, index + 2) : char;
|
||||
prepareToken("punctuation", token, index + token.length);
|
||||
@@ -472,5 +943,10 @@ export function formatJavaScriptForDisplay(source: string): string {
|
||||
index += token.length;
|
||||
}
|
||||
|
||||
return output.join("");
|
||||
const formatted = output.join("");
|
||||
try {
|
||||
return formatDisplayObjectLiterals(formatted, source);
|
||||
} catch {
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,22 +72,12 @@ type PrefixToken = "none" | "dot" | "colon" | "at" | "other";
|
||||
|
||||
function isIdentifierStart(character: string): boolean {
|
||||
const code = character.charCodeAt(0);
|
||||
return (
|
||||
character === "_" ||
|
||||
(code >= 65 && code <= 90) ||
|
||||
(code >= 97 && code <= 122) ||
|
||||
code >= 0x80
|
||||
);
|
||||
return character === "_" || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code >= 0x80;
|
||||
}
|
||||
|
||||
function isIdentifierContinue(character: string): boolean {
|
||||
const code = character.charCodeAt(0);
|
||||
return (
|
||||
isIdentifierStart(character) ||
|
||||
(code >= 48 && code <= 57) ||
|
||||
character === "!" ||
|
||||
character === "?"
|
||||
);
|
||||
return isIdentifierStart(character) || (code >= 48 && code <= 57) || character === "!" || character === "?";
|
||||
}
|
||||
|
||||
function isHorizontalWhitespace(character: string): boolean {
|
||||
@@ -207,10 +197,7 @@ function consumeQuotedLiteral(source: string, start: number, kind: QuoteKind): n
|
||||
|
||||
const character = source[index];
|
||||
if (character === "#") {
|
||||
index =
|
||||
source[index + 1] === "="
|
||||
? consumeBlockComment(source, index)
|
||||
: consumeLineComment(source, index);
|
||||
index = source[index + 1] === "=" ? consumeBlockComment(source, index) : consumeLineComment(source, index);
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
@@ -342,9 +329,7 @@ export function formatJuliaForDisplay(source: string): string {
|
||||
if (character === "#") {
|
||||
beginContent(false, false);
|
||||
const commentEnd =
|
||||
source[index + 1] === "="
|
||||
? consumeBlockComment(source, index)
|
||||
: consumeLineComment(source, index);
|
||||
source[index + 1] === "=" ? consumeBlockComment(source, index) : consumeLineComment(source, index);
|
||||
const containsNewline = appendOpaque(index, commentEnd);
|
||||
if (containsNewline) {
|
||||
canEndExpression = false;
|
||||
|
||||
@@ -156,9 +156,7 @@ function canOpenSuite(kind: HeaderKind, hasPayload: boolean): boolean {
|
||||
|
||||
function matchingCloser(opener: string, closer: string): boolean {
|
||||
return (
|
||||
(opener === "(" && closer === ")") ||
|
||||
(opener === "[" && closer === "]") ||
|
||||
(opener === "{" && closer === "}")
|
||||
(opener === "(" && closer === ")") || (opener === "[" && closer === "]") || (opener === "{" && closer === "}")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,7 +224,6 @@ function formatPythonPrefix(source: string): string {
|
||||
statementChain = null;
|
||||
}
|
||||
|
||||
|
||||
function flushHorizontal(): void {
|
||||
if (pendingHorizontal.length === 0) return;
|
||||
chunks.push(pendingHorizontal.join("").replaceAll("\t", " "));
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
const INDENT = " ";
|
||||
|
||||
const OPENING_KEYWORDS = new Set(["class", "module", "def", "if", "unless", "case", "begin", "while", "until", "for"]);
|
||||
const BRANCH_KEYWORDS = new Set(["else", "elsif", "when", "rescue", "ensure"]);
|
||||
const REGEXP_PREFIX_KEYWORDS = new Set([
|
||||
"and",
|
||||
"begin",
|
||||
"case",
|
||||
"do",
|
||||
"else",
|
||||
"elsif",
|
||||
"if",
|
||||
"in",
|
||||
"not",
|
||||
"or",
|
||||
"raise",
|
||||
"rescue",
|
||||
"return",
|
||||
"then",
|
||||
"unless",
|
||||
"until",
|
||||
"when",
|
||||
"while",
|
||||
"yield",
|
||||
]);
|
||||
const OPENING_KEYWORDS: Record<string, true> = {
|
||||
begin: true,
|
||||
case: true,
|
||||
class: true,
|
||||
def: true,
|
||||
for: true,
|
||||
if: true,
|
||||
module: true,
|
||||
unless: true,
|
||||
until: true,
|
||||
while: true,
|
||||
};
|
||||
|
||||
interface WordToken {
|
||||
kind: "word";
|
||||
value: string;
|
||||
eligible: boolean;
|
||||
}
|
||||
const BRANCH_KEYWORDS: Record<string, true> = {
|
||||
else: true,
|
||||
elsif: true,
|
||||
ensure: true,
|
||||
rescue: true,
|
||||
when: true,
|
||||
};
|
||||
|
||||
interface PunctuationToken {
|
||||
kind: "punctuation";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface LiteralToken {
|
||||
kind: "literal";
|
||||
}
|
||||
|
||||
type StructuralToken = WordToken | PunctuationToken | LiteralToken;
|
||||
const PAIRED_DELIMITERS: Record<string, string> = {
|
||||
"(": ")",
|
||||
"[": "]",
|
||||
"{": "}",
|
||||
"<": ">",
|
||||
};
|
||||
|
||||
type Quote = "'" | '"' | "`";
|
||||
|
||||
@@ -59,23 +46,16 @@ interface PercentContext {
|
||||
escaped: boolean;
|
||||
}
|
||||
|
||||
interface RegexpContext {
|
||||
kind: "regexp";
|
||||
escaped: boolean;
|
||||
inCharacterClass: boolean;
|
||||
}
|
||||
|
||||
interface InterpolationContext {
|
||||
kind: "interpolation";
|
||||
braceDepth: number;
|
||||
canStartExpression: boolean;
|
||||
}
|
||||
|
||||
interface CommentContext {
|
||||
kind: "comment";
|
||||
}
|
||||
|
||||
type LexicalContext = QuotedContext | PercentContext | RegexpContext | InterpolationContext | CommentContext;
|
||||
type LexicalContext = QuotedContext | PercentContext | InterpolationContext | CommentContext;
|
||||
|
||||
interface PercentLiteralStart {
|
||||
end: number;
|
||||
@@ -84,11 +64,6 @@ interface PercentLiteralStart {
|
||||
interpolated: boolean;
|
||||
}
|
||||
|
||||
interface LineLayout {
|
||||
indent: number;
|
||||
nextDepth: number;
|
||||
}
|
||||
|
||||
function isHorizontalWhitespace(character: string): boolean {
|
||||
return character === " " || character === "\t" || character === "\f" || character === "\v";
|
||||
}
|
||||
@@ -105,29 +80,7 @@ function isIdentifierPart(character: string | undefined): boolean {
|
||||
return isIdentifierStart(character) || (code >= 48 && code <= 57);
|
||||
}
|
||||
|
||||
function identifierEnd(source: string, start: number): number {
|
||||
let end = start + 1;
|
||||
while (isIdentifierPart(source[end])) end++;
|
||||
if (source[end] === "?" || source[end] === "!") end++;
|
||||
return end;
|
||||
}
|
||||
|
||||
function pairedDelimiter(open: string): string {
|
||||
switch (open) {
|
||||
case "(":
|
||||
return ")";
|
||||
case "[":
|
||||
return "]";
|
||||
case "{":
|
||||
return "}";
|
||||
case "<":
|
||||
return ">";
|
||||
default:
|
||||
return open;
|
||||
}
|
||||
}
|
||||
|
||||
function percentLiteralStart(source: string, start: number): PercentLiteralStart | undefined {
|
||||
function findPercentLiteral(source: string, start: number): PercentLiteralStart | undefined {
|
||||
let delimiterIndex = start + 1;
|
||||
let type = "";
|
||||
const candidateType = source[delimiterIndex];
|
||||
@@ -137,83 +90,30 @@ function percentLiteralStart(source: string, start: number): PercentLiteralStart
|
||||
}
|
||||
|
||||
const open = source[delimiterIndex];
|
||||
if (open === undefined || /[A-Za-z0-9_\s]/.test(open)) return undefined;
|
||||
|
||||
if (open === undefined || isIdentifierPart(open) || /\s/.test(open)) return undefined;
|
||||
return {
|
||||
end: delimiterIndex + 1,
|
||||
open,
|
||||
close: pairedDelimiter(open),
|
||||
close: PAIRED_DELIMITERS[open] ?? open,
|
||||
interpolated: type === "" || type === "Q" || type === "W" || type === "I" || type === "x" || type === "r",
|
||||
};
|
||||
}
|
||||
|
||||
function isMatchingDelimiter(open: string, close: string): boolean {
|
||||
return (
|
||||
(open === "(" && close === ")") ||
|
||||
(open === "[" && close === "]") ||
|
||||
(open === "{" && close === "}")
|
||||
);
|
||||
}
|
||||
|
||||
function isStandaloneAssignment(tokens: StructuralToken[], index: number): boolean {
|
||||
const previous = tokens[index - 1];
|
||||
const next = tokens[index + 1];
|
||||
if (next?.kind === "punctuation" && (next.value === "=" || next.value === ">" || next.value === "(")) return false;
|
||||
if (
|
||||
previous?.kind === "punctuation" &&
|
||||
(previous.value === "=" || previous.value === "!" || previous.value === "<" || previous.value === ">" || previous.value === "~")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function lineLayout(tokens: StructuralToken[], depth: number): LineLayout {
|
||||
const first = tokens[0];
|
||||
const leadingKeyword = first?.kind === "word" && first.eligible ? first.value : undefined;
|
||||
const leadingEnd = leadingKeyword === "end";
|
||||
const branch = leadingKeyword !== undefined && BRANCH_KEYWORDS.has(leadingKeyword);
|
||||
|
||||
let indent = depth;
|
||||
if (leadingEnd || branch) indent = Math.max(0, depth - 1);
|
||||
|
||||
let endCount = 0;
|
||||
let hasDo = false;
|
||||
for (const token of tokens) {
|
||||
if (token.kind !== "word" || !token.eligible) continue;
|
||||
if (token.value === "end") endCount++;
|
||||
if (token.value === "do") hasDo = true;
|
||||
}
|
||||
|
||||
let opens = leadingKeyword !== undefined && OPENING_KEYWORDS.has(leadingKeyword);
|
||||
if (leadingKeyword === "def") {
|
||||
for (let index = 1; index < tokens.length; index++) {
|
||||
const token = tokens[index];
|
||||
if (token.kind === "punctuation" && token.value === "=" && isStandaloneAssignment(tokens, index)) {
|
||||
opens = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!leadingEnd && !branch && hasDo) opens = true;
|
||||
|
||||
return {
|
||||
indent,
|
||||
nextDepth: Math.max(0, depth + (opens ? 1 : 0) - endCount),
|
||||
};
|
||||
}
|
||||
|
||||
function formatRubyPrefix(source: string): string {
|
||||
const output: string[] = [];
|
||||
const contexts: LexicalContext[] = [];
|
||||
const delimiters: string[] = [];
|
||||
let tokens: StructuralToken[] = [];
|
||||
let lineParts: string[] = [];
|
||||
let lineHasVisibleText = false;
|
||||
let preserveLeadingWhitespace = false;
|
||||
let pendingVirtualBreak = false;
|
||||
let pendingSemicolonBreak = false;
|
||||
let blockDepth = 0;
|
||||
let rootCanStartExpression = true;
|
||||
let hasSignificantToken = false;
|
||||
let leadingWord: string | null = null;
|
||||
let previousToken = "";
|
||||
let hasDo = false;
|
||||
let endCount = 0;
|
||||
let endlessDefinition = false;
|
||||
|
||||
const append = (text: string): void => {
|
||||
lineParts.push(text);
|
||||
@@ -226,36 +126,35 @@ function formatRubyPrefix(source: string): string {
|
||||
}
|
||||
};
|
||||
|
||||
const currentCodeCanStartExpression = (): boolean => {
|
||||
for (let index = contexts.length - 1; index >= 0; index--) {
|
||||
const context = contexts[index];
|
||||
if (context.kind === "interpolation") return context.canStartExpression;
|
||||
const observeOtherToken = (value: string): void => {
|
||||
if (!hasSignificantToken) {
|
||||
hasSignificantToken = true;
|
||||
leadingWord = null;
|
||||
}
|
||||
return rootCanStartExpression;
|
||||
};
|
||||
|
||||
const setCurrentCodeCanStartExpression = (value: boolean): void => {
|
||||
for (let index = contexts.length - 1; index >= 0; index--) {
|
||||
const context = contexts[index];
|
||||
if (context.kind === "interpolation") {
|
||||
context.canStartExpression = value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
rootCanStartExpression = value;
|
||||
previousToken = value;
|
||||
};
|
||||
|
||||
const resetLine = (): void => {
|
||||
tokens = [];
|
||||
lineParts = [];
|
||||
lineHasVisibleText = false;
|
||||
preserveLeadingWhitespace = contexts.length > 0 || delimiters.length > 0;
|
||||
hasSignificantToken = false;
|
||||
leadingWord = null;
|
||||
previousToken = "";
|
||||
hasDo = false;
|
||||
endCount = 0;
|
||||
endlessDefinition = false;
|
||||
};
|
||||
|
||||
const flushLine = (ending: string): void => {
|
||||
const raw = lineParts.join("");
|
||||
const layout = lineLayout(tokens, blockDepth);
|
||||
blockDepth = layout.nextDepth;
|
||||
const leadingEnd = leadingWord === "end";
|
||||
const branch = leadingWord !== null && BRANCH_KEYWORDS[leadingWord] === true;
|
||||
const opens =
|
||||
!endlessDefinition &&
|
||||
((leadingWord !== null && OPENING_KEYWORDS[leadingWord] === true) || (!leadingEnd && !branch && hasDo));
|
||||
const indentation = leadingEnd || branch ? Math.max(0, blockDepth - 1) : blockDepth;
|
||||
blockDepth = Math.max(0, blockDepth + (opens ? 1 : 0) - endCount);
|
||||
|
||||
if (preserveLeadingWhitespace) {
|
||||
output.push(raw, ending);
|
||||
@@ -265,15 +164,7 @@ function formatRubyPrefix(source: string): string {
|
||||
let contentStart = 0;
|
||||
while (contentStart < raw.length && isHorizontalWhitespace(raw[contentStart])) contentStart++;
|
||||
const content = raw.slice(contentStart);
|
||||
output.push(content.length === 0 ? "" : INDENT.repeat(layout.indent) + content, ending);
|
||||
};
|
||||
|
||||
const addPunctuation = (value: string): void => {
|
||||
if (contexts.length === 0 && delimiters.length === 0) tokens.push({ kind: "punctuation", value });
|
||||
};
|
||||
|
||||
const addLiteral = (): void => {
|
||||
if (contexts.length === 0 && delimiters.length === 0) tokens.push({ kind: "literal" });
|
||||
output.push(content.length === 0 ? "" : INDENT.repeat(indentation) + content, ending);
|
||||
};
|
||||
|
||||
for (let index = 0; index < source.length; ) {
|
||||
@@ -283,23 +174,16 @@ function formatRubyPrefix(source: string): string {
|
||||
const top = contexts[contexts.length - 1];
|
||||
if (top?.kind === "comment") {
|
||||
contexts.pop();
|
||||
} else if (top?.kind === "quoted" || top?.kind === "percent" || top?.kind === "regexp") {
|
||||
} else if (top?.kind === "quoted" || top?.kind === "percent") {
|
||||
top.escaped = false;
|
||||
}
|
||||
|
||||
const codeContext = contexts[contexts.length - 1];
|
||||
if (codeContext?.kind === "interpolation") {
|
||||
codeContext.canStartExpression = true;
|
||||
} else if (contexts.length === 0 && delimiters.length === 0) {
|
||||
rootCanStartExpression = true;
|
||||
}
|
||||
|
||||
if (pendingVirtualBreak && !lineHasVisibleText) {
|
||||
pendingVirtualBreak = false;
|
||||
if (pendingSemicolonBreak && !lineHasVisibleText) {
|
||||
pendingSemicolonBreak = false;
|
||||
resetLine();
|
||||
} else {
|
||||
flushLine(ending);
|
||||
pendingVirtualBreak = false;
|
||||
pendingSemicolonBreak = false;
|
||||
resetLine();
|
||||
}
|
||||
index += ending.length;
|
||||
@@ -327,14 +211,11 @@ function formatRubyPrefix(source: string): string {
|
||||
}
|
||||
if (top.interpolated && character === "#" && source[index + 1] === "{") {
|
||||
append("{");
|
||||
contexts.push({ kind: "interpolation", braceDepth: 1, canStartExpression: true });
|
||||
contexts.push({ kind: "interpolation", braceDepth: 1 });
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (character === top.quote) {
|
||||
contexts.pop();
|
||||
setCurrentCodeCanStartExpression(false);
|
||||
}
|
||||
if (character === top.quote) contexts.pop();
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
@@ -353,7 +234,7 @@ function formatRubyPrefix(source: string): string {
|
||||
}
|
||||
if (top.interpolated && character === "#" && source[index + 1] === "{") {
|
||||
append("{");
|
||||
contexts.push({ kind: "interpolation", braceDepth: 1, canStartExpression: true });
|
||||
contexts.push({ kind: "interpolation", braceDepth: 1 });
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
@@ -361,40 +242,7 @@ function formatRubyPrefix(source: string): string {
|
||||
top.depth++;
|
||||
} else if (character === top.close) {
|
||||
top.depth--;
|
||||
if (top.depth === 0) {
|
||||
contexts.pop();
|
||||
setCurrentCodeCanStartExpression(false);
|
||||
}
|
||||
}
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (top?.kind === "regexp") {
|
||||
append(character);
|
||||
if (top.escaped) {
|
||||
top.escaped = false;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
top.escaped = true;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (character === "#" && source[index + 1] === "{") {
|
||||
append("{");
|
||||
contexts.push({ kind: "interpolation", braceDepth: 1, canStartExpression: true });
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (character === "[" && !top.inCharacterClass) {
|
||||
top.inCharacterClass = true;
|
||||
} else if (character === "]" && top.inCharacterClass) {
|
||||
top.inCharacterClass = false;
|
||||
} else if (character === "/" && !top.inCharacterClass) {
|
||||
contexts.pop();
|
||||
setCurrentCodeCanStartExpression(false);
|
||||
if (top.depth === 0) contexts.pop();
|
||||
}
|
||||
index++;
|
||||
continue;
|
||||
@@ -402,16 +250,10 @@ function formatRubyPrefix(source: string): string {
|
||||
|
||||
const interpolation = top?.kind === "interpolation" ? top : undefined;
|
||||
const inRootCode = interpolation === undefined;
|
||||
|
||||
if (interpolation !== undefined && character === "}") {
|
||||
append(character);
|
||||
interpolation.braceDepth--;
|
||||
if (interpolation.braceDepth === 0) {
|
||||
contexts.pop();
|
||||
setCurrentCodeCanStartExpression(false);
|
||||
} else {
|
||||
interpolation.canStartExpression = false;
|
||||
}
|
||||
if (interpolation.braceDepth === 0) contexts.pop();
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
@@ -424,72 +266,51 @@ function formatRubyPrefix(source: string): string {
|
||||
}
|
||||
|
||||
if (character === "'" || character === '"' || character === "`") {
|
||||
addLiteral();
|
||||
if (inRootCode && delimiters.length === 0) observeOtherToken("literal");
|
||||
append(character);
|
||||
contexts.push({
|
||||
kind: "quoted",
|
||||
quote: character,
|
||||
interpolated: character !== "'",
|
||||
escaped: false,
|
||||
});
|
||||
contexts.push({ kind: "quoted", quote: character, interpolated: character !== "'", escaped: false });
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "%") {
|
||||
const start = percentLiteralStart(source, index);
|
||||
if (start !== undefined) {
|
||||
addLiteral();
|
||||
append(source.slice(index, start.end));
|
||||
const literal = findPercentLiteral(source, index);
|
||||
if (literal !== undefined) {
|
||||
if (inRootCode && delimiters.length === 0) observeOtherToken("literal");
|
||||
append(source.slice(index, literal.end));
|
||||
contexts.push({
|
||||
kind: "percent",
|
||||
open: start.open,
|
||||
close: start.close,
|
||||
open: literal.open,
|
||||
close: literal.close,
|
||||
depth: 1,
|
||||
interpolated: start.interpolated,
|
||||
interpolated: literal.interpolated,
|
||||
escaped: false,
|
||||
});
|
||||
index = start.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (character === "/" && currentCodeCanStartExpression()) {
|
||||
addLiteral();
|
||||
append(character);
|
||||
contexts.push({ kind: "regexp", escaped: false, inCharacterClass: false });
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "?" && currentCodeCanStartExpression()) {
|
||||
const next = source[index + 1];
|
||||
if (next !== undefined && !/\s/.test(next)) {
|
||||
addLiteral();
|
||||
let end = index + 2;
|
||||
if (next === "\\" && source[end] !== undefined) end++;
|
||||
append(source.slice(index, end));
|
||||
setCurrentCodeCanStartExpression(false);
|
||||
index = end;
|
||||
index = literal.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isIdentifierStart(character)) {
|
||||
const end = identifierEnd(source, index);
|
||||
let end = index + 1;
|
||||
while (isIdentifierPart(source[end])) end++;
|
||||
if (source[end] === "?" || source[end] === "!") end++;
|
||||
const word = source.slice(index, end);
|
||||
append(word);
|
||||
|
||||
if (inRootCode && delimiters.length === 0) {
|
||||
const previous = tokens[tokens.length - 1];
|
||||
const blockedByPrefix =
|
||||
previous?.kind === "punctuation" &&
|
||||
(previous.value === ":" || previous.value === "." || previous.value === "@" || previous.value === "$");
|
||||
previousToken === ":" || previousToken === "." || previousToken === "@" || previousToken === "$";
|
||||
const label = source[end] === ":" && source[end + 1] !== ":";
|
||||
tokens.push({ kind: "word", value: word, eligible: !blockedByPrefix && !label });
|
||||
const eligible = !blockedByPrefix && !label;
|
||||
if (!hasSignificantToken) {
|
||||
hasSignificantToken = true;
|
||||
leadingWord = eligible ? word : null;
|
||||
}
|
||||
if (eligible && word === "do") hasDo = true;
|
||||
if (eligible && word === "end") endCount++;
|
||||
previousToken = "word";
|
||||
}
|
||||
|
||||
setCurrentCodeCanStartExpression(REGEXP_PREFIX_KEYWORDS.has(word));
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
@@ -497,16 +318,14 @@ function formatRubyPrefix(source: string): string {
|
||||
if (interpolation !== undefined && character === "{") {
|
||||
append(character);
|
||||
interpolation.braceDepth++;
|
||||
interpolation.canStartExpression = true;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inRootCode && (character === "(" || character === "[" || character === "{")) {
|
||||
addPunctuation(character);
|
||||
if (delimiters.length === 0) observeOtherToken(character);
|
||||
append(character);
|
||||
delimiters.push(character);
|
||||
rootCanStartExpression = true;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
@@ -514,9 +333,8 @@ function formatRubyPrefix(source: string): string {
|
||||
if (inRootCode && (character === ")" || character === "]" || character === "}")) {
|
||||
append(character);
|
||||
const open = delimiters[delimiters.length - 1];
|
||||
if (open !== undefined && isMatchingDelimiter(open, character)) delimiters.pop();
|
||||
addPunctuation(character);
|
||||
rootCanStartExpression = false;
|
||||
if (open !== undefined && PAIRED_DELIMITERS[open] === character) delimiters.pop();
|
||||
if (delimiters.length === 0) observeOtherToken(character);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
@@ -524,36 +342,31 @@ function formatRubyPrefix(source: string): string {
|
||||
if (character === ";") {
|
||||
append(character);
|
||||
if (inRootCode && delimiters.length === 0) {
|
||||
addPunctuation(character);
|
||||
rootCanStartExpression = true;
|
||||
observeOtherToken(character);
|
||||
flushLine("\n");
|
||||
pendingVirtualBreak = true;
|
||||
pendingSemicolonBreak = true;
|
||||
resetLine();
|
||||
} else {
|
||||
setCurrentCodeCanStartExpression(true);
|
||||
}
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
append(character);
|
||||
if (isHorizontalWhitespace(character)) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inRootCode && delimiters.length === 0) tokens.push({ kind: "punctuation", value: character });
|
||||
if (character === "." || character === ")" || character === "]" || character === "}") {
|
||||
setCurrentCodeCanStartExpression(false);
|
||||
} else if ("=,:!~+-*%&|^<>?".includes(character) || character === "/") {
|
||||
setCurrentCodeCanStartExpression(true);
|
||||
} else {
|
||||
setCurrentCodeCanStartExpression(false);
|
||||
if (inRootCode && delimiters.length === 0 && !isHorizontalWhitespace(character)) {
|
||||
if (
|
||||
character === "=" &&
|
||||
leadingWord === "def" &&
|
||||
/\s/.test(source[index - 1] ?? "") &&
|
||||
source[index + 1] !== "="
|
||||
) {
|
||||
endlessDefinition = true;
|
||||
}
|
||||
observeOtherToken(character);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
if (lineParts.length > 0 && !(pendingVirtualBreak && !lineHasVisibleText)) flushLine("");
|
||||
if (lineParts.length > 0 && !(pendingSemicolonBreak && !lineHasVisibleText)) flushLine("");
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ import { Markdown, Text } from "@oh-my-pi/pi-tui";
|
||||
import { formatNumber } from "@oh-my-pi/pi-utils";
|
||||
import { settings } from "../config/settings";
|
||||
import type { EvalCellResult, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
|
||||
import { formatEvalCodeForDisplay } from "./eval-format";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
|
||||
import { truncateToVisualLines } from "../modes/components/visual-truncate";
|
||||
import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
|
||||
import { markFramedBlockComponent, outputBlockContentWidth, renderCodeCell } from "../tui";
|
||||
import { formatEvalCodeForDisplay } from "./eval-format";
|
||||
import {
|
||||
JSON_TREE_MAX_DEPTH_COLLAPSED,
|
||||
JSON_TREE_MAX_DEPTH_EXPANDED,
|
||||
|
||||
@@ -2,41 +2,40 @@ import { describe, expect, it } from "bun:test";
|
||||
import { formatJavaScriptForDisplay } from "@oh-my-pi/pi-coding-agent/tools/eval-format/javascript";
|
||||
|
||||
describe("formatJavaScriptForDisplay", () => {
|
||||
it("expands compact control flow, objects, and arrays", () => {
|
||||
it("expands compact control flow and keeps short objects and arrays inline", () => {
|
||||
expect(formatJavaScriptForDisplay("if (ready){const item = { value: 1 };use(item);}else{fallback();}")).toBe(
|
||||
[
|
||||
"if (ready) {",
|
||||
" const item = {",
|
||||
" value: 1",
|
||||
" };",
|
||||
" use(item);",
|
||||
"} else {",
|
||||
" fallback();",
|
||||
"}",
|
||||
].join("\n"),
|
||||
["if (ready) {", " const item = { value: 1 };", " use(item);", "} else {", " fallback();", "}"].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
|
||||
expect(formatJavaScriptForDisplay("const rows = [{ value: 1 },{ value: 2 }];")).toBe(
|
||||
"const rows = [{ value: 1 }, { value: 2 }];",
|
||||
);
|
||||
});
|
||||
|
||||
it("explodes object literals that exceed the display width", () => {
|
||||
const source =
|
||||
"const box={left:rect.left+offset.left,right:rect.right+offset.right,top:rect.top+offset.top,bottom:rect.bottom+offset.bottom};";
|
||||
expect(formatJavaScriptForDisplay(source)).toBe(
|
||||
[
|
||||
"const rows = [{",
|
||||
" value: 1",
|
||||
"}, {",
|
||||
" value: 2",
|
||||
"}];",
|
||||
"const box = {",
|
||||
" left: rect.left + offset.left,",
|
||||
" right: rect.right + offset.right,",
|
||||
" top: rect.top + offset.top,",
|
||||
" bottom: rect.bottom + offset.bottom",
|
||||
"};",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps for-loop header semicolons inline", () => {
|
||||
it("normalizes operator spacing, preserves angle brackets, and keeps for-loop header semicolons inline", () => {
|
||||
expect(formatJavaScriptForDisplay("for(;;){tick();}for(let i=0;i<2;i++){work(i);}")).toBe(
|
||||
[
|
||||
"for (;;) {",
|
||||
" tick();",
|
||||
"}",
|
||||
"for (let i=0; i<2; i++) {",
|
||||
" work(i);",
|
||||
"}",
|
||||
].join("\n"),
|
||||
["for (;;) {", " tick();", "}", "for (let i = 0; i<2; i++) {", " work(i);", "}"].join("\n"),
|
||||
);
|
||||
// Angle brackets never get binary-operator spacing: generics would mangle.
|
||||
expect(formatJavaScriptForDisplay("const seen=new Map<string,number>();")).toBe(
|
||||
"const seen = new Map<string, number>();",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { formatRubyForDisplay } from "@oh-my-pi/pi-coding-agent/tools/eval-format/ruby";
|
||||
|
||||
describe("formatRubyForDisplay", () => {
|
||||
it("expands genuinely compact nested blocks", () => {
|
||||
const source = 'class Greeter;def call(name);if name;puts "Hello, #{name}";else;puts "nobody";end;end;end';
|
||||
|
||||
expect(formatRubyForDisplay(source)).toBe(
|
||||
[
|
||||
"class Greeter;",
|
||||
" def call(name);",
|
||||
" if name;",
|
||||
' puts "Hello, #{name}";',
|
||||
" else;",
|
||||
' puts "nobody";',
|
||||
" end;",
|
||||
" end;",
|
||||
"end",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps semicolons and keywords inside literals, symbols, interpolation, and comments", () => {
|
||||
const source =
|
||||
'puts "if; \\"end\\"; #{label == "else;"}"; puts \'do; end\'; puts `case; end`; puts :end; puts :"if;else"; puts %q{class; {end;}}; puts %Q[do; #{value; other}]; # if; end';
|
||||
|
||||
expect(formatRubyForDisplay(source)).toBe(
|
||||
[
|
||||
'puts "if; \\"end\\"; #{label == "else;"}";',
|
||||
"puts 'do; end';",
|
||||
"puts `case; end`;",
|
||||
"puts :end;",
|
||||
'puts :"if;else";',
|
||||
"puts %q{class; {end;}};",
|
||||
"puts %Q[do; #{value; other}];",
|
||||
"# if; end",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("indents do blocks and aligns case branches", () => {
|
||||
const source = "items.each do |item|;case item;when 1;puts item;else;next;end;end";
|
||||
|
||||
expect(formatRubyForDisplay(source)).toBe(
|
||||
[
|
||||
"items.each do |item|;",
|
||||
" case item;",
|
||||
" when 1;",
|
||||
" puts item;",
|
||||
" else;",
|
||||
" next;",
|
||||
" end;",
|
||||
"end",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves modifier-form statements conservative", () => {
|
||||
expect(formatRubyForDisplay("work if ready; cleanup unless dry_run")).toBe(
|
||||
"work if ready;\ncleanup unless dry_run",
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates unfinished literal and block prefixes without inventing closers", () => {
|
||||
const unfinished = [
|
||||
'"unfinished; \\"still open',
|
||||
"'unfinished; end",
|
||||
"`unfinished; do",
|
||||
"%q{unfinished; end",
|
||||
"%Q[before #{value; other}",
|
||||
"items.each do; work",
|
||||
];
|
||||
|
||||
for (const source of unfinished) expect(() => formatRubyForDisplay(source)).not.toThrow();
|
||||
expect(formatRubyForDisplay(unfinished[0])).toBe(unfinished[0]);
|
||||
expect(formatRubyForDisplay(unfinished[3])).toBe(unfinished[3]);
|
||||
expect(formatRubyForDisplay(unfinished[4])).toBe(unfinished[4]);
|
||||
expect(formatRubyForDisplay(unfinished[5])).toBe("items.each do;\n work");
|
||||
});
|
||||
|
||||
it("is idempotent and preserves already-readable multiline code", () => {
|
||||
const compact = "begin;work if ready;rescue Error;recover;ensure;cleanup;end";
|
||||
const formatted = formatRubyForDisplay(compact);
|
||||
expect(formatRubyForDisplay(formatted)).toBe(formatted);
|
||||
|
||||
const readable = ["module Jobs", " while ready", " work if enabled", " end", "end"].join("\n");
|
||||
expect(formatRubyForDisplay(readable)).toBe(readable);
|
||||
});
|
||||
|
||||
it("never changes previously committed lines while a prefix grows", () => {
|
||||
const source =
|
||||
"class Runner;items.each do |item|;if item;puts %Q[value; #{item}];else;# keep if; end\nnext;end;end;end";
|
||||
let committed = "";
|
||||
|
||||
for (let length = 0; length <= source.length; length++) {
|
||||
const formatted = formatRubyForDisplay(source.slice(0, length));
|
||||
expect(formatted.startsWith(committed)).toBe(true);
|
||||
const lastBreak = formatted.lastIndexOf("\n");
|
||||
if (lastBreak >= 0) committed = formatted.slice(0, lastBreak + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user