/** * buildView (mod.ts) chooses diff vs source mode, the matching lazy semantic * service, and the right editable source. The interactive entry constructs the * semantic service only when launching the pager, so these call the returned * closure directly to exercise both the diff and source semantics paths. */ import { assert, assertEquals, assertThrows } from "@std/assert"; import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; import { markdownLanguage } from "../lib/view/languages/markdown/language.ts"; import { plainTextLanguage } from "../lib/view/languages/plain-text/language.ts"; import { pythonLanguage } from "../lib/view/languages/python/language.ts"; import { buildPreparedView, buildView, ViewError } from "../lib/view/mod.ts"; // These cases color Python through its synchronous entry points, which show // plain text until its parser has loaded. await pythonLanguage.prepare!(); const SRC = "export const x = 1;\nconst y = x + 1;\n"; const TRANSFORMED = `// transformed: /app.ts ${SRC}`; const DIFF = `diff --git a/a.ts b/a.ts --- a/a.ts +++ b/a.ts @@ -1,2 +1,2 @@ -const x = 1; +const x = 2; const y = x; `; Deno.test("buildView: a named source file is editable and its semantics closure runs", () => { const r = buildView(SRC, "transformed.ts"); assert(r.doc.lines.length > 0); const s = r.semantics(); // runs createSemantics (the source closure body) assert(s === undefined || typeof s.prewarm === "function"); assertEquals(r.editSource.editable, true, "a named file is editable"); }); Deno.test("buildView: an ordinary pipe with no file is read-only plain text", () => { const r = buildView(SRC); assertEquals(r.doc.structure, []); assertEquals( r.doc.lines.flatMap((line) => line.spans.map((span) => span.cls)), ["plain", "plain"], ); assertEquals(r.semantics(), undefined); assertEquals(r.editSource.editable, false); }); Deno.test("buildView: decodes buffered text after byte detection completes", () => { const text = "#!/usr/bin/env python3\ndef greet():\n pass\n"; const bytes = new TextEncoder().encode(text); const view = buildView({ kind: "bytes", bytes, byteLanguageDetectionComplete: true, extent: { byteLength: bytes.length, complete: true }, }); assert( view.doc.lines.flatMap((line) => line.spans).some((span) => span.cls === "storageKeyword" && span.text === "def" ), "text decoding still applies shebang language selection", ); }); Deno.test("buildView: source syntax is not guessed from content", () => { const examples = [ ["JSON", '{"value": true}\n'], ["Markdown", "# Heading\n"], ["YAML", "value: true\n"], ["Python", "def greet():\n pass\n"], ] as const; for (const [name, source] of examples) { const classes = buildView(source).doc.lines.flatMap((line) => line.spans.map((span) => span.cls) ); assert( classes.length > 0 && classes.every((className) => className === "plain"), `${name}-shaped unnamed source remains plain text`, ); } }); Deno.test("buildView: transformed compiler output keeps the TypeScript default", () => { const r = buildView(TRANSFORMED); assert( r.doc.structure.some((node) => node.kind === "section"), "the transformed module has TypeScript structure", ); assert( r.doc.lines.flatMap((line) => line.spans).some((span) => span.cls === "storageKeyword" ), "the transformed module has TypeScript highlighting", ); const semantics = r.semantics(); assert(semantics, "transformed output has TypeScript semantics"); assertEquals( r.editSource.parse(TRANSFORMED), r.doc, "read-only reparsing keeps the selected language", ); }); describe("decoded buildView input", () => { it("keeps transformed and diff selectors visible after a UTF-8 BOM", () => { const withBom = (text: string): Uint8Array => new Uint8Array([ 0xef, 0xbb, 0xbf, ...new TextEncoder().encode(text), ]); const transformed = buildView(withBom(TRANSFORMED)); expect( transformed.doc.structure.some((node) => node.kind === "section"), ).toBe(true); const diffText = `diff --git a/value.ts b/value.ts --- a/value.ts +++ b/value.ts @@ -1 +1 @@ -const value = 1; +const value = 2; `; const diff = buildView(withBom(diffText)); expect(diff.editSource.isDiff).toBe(true); expect(diff.doc.text).toBe(diffText); }); }); Deno.test("buildView: a virtual filename selects piped source without making it editable", () => { const source = 'def greet(name):\n return f"Hello, {name}"\n'; const r = buildView(source, undefined, undefined, { fileName: "scripts/greet.py", }); assert( r.doc.lines.flatMap((line) => line.spans).some((span) => span.cls === "storageKeyword" ), "the virtual Python filename selects Python highlighting", ); assertEquals(r.editSource.editable, false); assertEquals(r.editSource.path, undefined); assertEquals(r.editSource.parse(source), r.doc); }); Deno.test("buildPreparedView: a rename loads the parser of the side left behind", async () => { // A diff resolves its two sides separately, so a file renamed out of one // language is still parsed as that language. const diff = [ "diff --git a/example.py b/example.txt", "--- a/example.py", "+++ b/example.txt", "@@ -1,2 +1,2 @@", "-def old_name():", "- return 1", "+plain text now", "+second line", "", ].join("\n"); const { doc } = await buildPreparedView(diff); assertEquals( doc.lines.map((line) => line.spans.map((span) => span.text).join("")).join( "\n", ), diff, ); assert( doc.lines.some((line) => line.spans.some((span) => span.text === "old_name" && span.cls === "functionName" ) ), "the removed side kept its Python coloring", ); }); Deno.test("buildView: a shebang selects an extensionless source and its editor", () => { const source = "#!/usr/bin/env python3\ndef greet():\n return 'hello'\n"; const piped = buildView(source); const file = buildView(source, "greet"); for (const view of [piped, file]) { assert( view.doc.lines.flatMap((line) => line.spans).some((span) => span.cls === "storageKeyword" && span.text === "def" ), "the Python shebang selects Python highlighting", ); assertEquals(view.editSource.parse(source), view.doc); } assertEquals(piped.editSource.editable, false); assertEquals(file.editSource.editable, true); }); Deno.test("buildView: an explicit language takes priority over a virtual filename", () => { const source = "# **Piped heading**\n"; const r = buildView(source, undefined, undefined, { language: markdownLanguage, fileName: "notes.txt", }); assert( r.doc.structure.some((node) => node.kind === "section" && node.name === "**Piped heading**" ), "the explicit Markdown language overrides the plain-text filename", ); assertEquals( r.editSource.render?.(r.doc).lines[0].text, "Piped heading", ); }); Deno.test("buildView: explicit source selection suppresses automatic detection", () => { const transformed = buildView(TRANSFORMED, undefined, undefined, { language: plainTextLanguage, }); assertEquals(transformed.doc.structure, []); const filenameDiff = buildView(DIFF, undefined, undefined, { fileName: "piped.py", }); assertEquals(filenameDiff.editSource.isDiff, undefined); assert( !filenameDiff.doc.flatStructure.some((node) => node.kind === "hunk"), "the virtual source filename overrides diff content detection", ); const languageDiff = buildView(DIFF, undefined, undefined, { language: plainTextLanguage, }); assertEquals(languageDiff.editSource.isDiff, undefined); assert( !languageDiff.doc.flatStructure.some((node) => node.kind === "hunk"), "the explicit language overrides diff content detection", ); }); Deno.test("buildView: source selection rejects incompatible direct calls", () => { assertThrows( () => buildView(SRC, "actual.ts", undefined, { language: plainTextLanguage, }), ViewError, "cannot be used with a file argument", ); assertThrows( () => buildView(SRC, undefined, true, { fileName: "virtual.ts", }), ViewError, "--diff cannot be combined", ); }); Deno.test("buildView: transformed declarations without bodies retain function structure", () => { const r = buildView( "// transformed: /types.ts\nexport declare function run(): void;\n", ); const declaration = r.doc.flatStructure.find((node) => node.name === "run"); assert(declaration, "the declaration remains in the TypeScript structure"); assertEquals(declaration.kind, "function"); assertEquals(declaration.children, []); assertEquals(declaration.meta, { kind: "closure", params: [], returns: undefined, signature: "() → void", }); }); Deno.test("buildView: only an exact first-line transformed header selects TypeScript", () => { for ( const text of [ "ordinary text", `\n${TRANSFORMED}`, "// transformed:\nexport const x = 1;\n", "// transformed: \nexport const x = 1;\n", "// Transformed: /app.ts\nexport const x = 1;\n", ] ) { assertEquals(buildView(text).doc.structure, [], JSON.stringify(text)); } }); Deno.test("buildView: blank input remains a read-only source", () => { const r = buildView(" \n\t\n"); assertEquals(r.editSource.isDiff, undefined); assertEquals(r.editSource.editable, false); }); Deno.test("buildView: a diff takes the diff branch and a diff-semantics closure", () => { const r = buildView(DIFF); r.semantics(); // runs createDiffSemantics (the diff closure body) assert( r.doc.lines.some((l) => l.text.startsWith("@@")), "rendered as a diff", ); }); Deno.test("buildView: commit output with no file diff takes the commit-editing branch", () => { const commit = [ "commit 0123456789abcdef0123456789abcdef01234567", "Author: A B ", "Date: Wed Jul 1 12:00:00 2026 -0700", "", " Empty commit subject", "", ].join("\n"); const r = buildView(commit); assertEquals(r.editSource.isDiff, true); assertEquals(r.editSource.label, null); }); Deno.test("buildView: a commit with no message takes the commit branch", () => { const commit = [ "commit 0123456789abcdef0123456789abcdef01234567", "Author: A B ", "Date: Wed Jul 1 12:00:00 2026 -0700", "", ].join("\n"); const r = buildView(commit); assertEquals(r.editSource.isDiff, true); assertEquals(r.editSource.label, null); }); Deno.test("buildView: commit-looking prose without Git metadata stays a source file", () => { const text = [ "commit 0123456789abcdef0123456789abcdef01234567", "", " Ordinary prose", "", ].join("\n"); const named = buildView(text, "notes.txt"); const piped = buildView(text); assertEquals(named.editSource.isDiff, undefined); assertEquals(named.editSource.editable, true); assertEquals(piped.editSource.isDiff, undefined); assertEquals(piped.editSource.editable, false); }); Deno.test("buildView: a commit token followed by ordinary text stays a source file", () => { const r = buildView("commit deadbee\nordinary text\n", "notes.txt"); assertEquals(r.editSource.isDiff, undefined); assertEquals(r.editSource.editable, true); }); Deno.test("buildView: a standard commit header without its separator stays source", () => { const text = [ "commit 0123456789abcdef0123456789abcdef01234567", "Author: A B ", "Date: Wed Jul 1 12:00:00 2026 -0700", ].join("\n"); const r = buildView(text, "notes.txt"); assertEquals(r.editSource.isDiff, undefined); assertEquals(r.editSource.editable, true); }); Deno.test("buildView: an email commit header without its separator stays source", () => { const sha = "0123456789abcdef0123456789abcdef01234567"; const text = [ `From ${sha} Mon Sep 17 00:00:00 2001`, "From: A B ", "Date: Wed, 1 Jul 2026 12:00:00 -0700", "Subject: [PATCH] Subject", ].join("\n"); const r = buildView(text, "notes.txt"); assertEquals(r.editSource.isDiff, undefined); assertEquals(r.editSource.editable, true); }); Deno.test("buildView: Git's built-in commit header formats take the commit branch", () => { const sha = "0123456789abcdef0123456789abcdef01234567"; const formats = [ ["short", "Author: A B "], ["full", "Author: A B \nCommit: C D "], [ "fuller", "Author: A B \nAuthorDate: Wed Jul 1 12:00:00 2026 -0700\n" + "Commit: C D \nCommitDate: Wed Jul 1 12:00:00 2026 -0700", ], [ "raw", `tree ${"a".repeat(40)}\nauthor A B 1782932400 -0700\n` + "committer C D 1782932400 -0700", ], ]; for (const [format, headers] of formats) { const text = `commit ${sha}\n${headers}\n\n Subject\n`; assertEquals( buildView(text).editSource.isDiff, true, `${format} output is a commit view`, ); } }); Deno.test("buildView: compact and email commit formats retain diff ownership", () => { const sha = "0123456789abcdef0123456789abcdef01234567"; const formats = [ ["oneline", `${sha} Subject\n${DIFF}`], ["reference", `0123 (Subject, 2026-07-20)\n${DIFF}`], [ "email", [ `From ${sha} Mon Sep 17 00:00:00 2001`, "From: A B ", "Date: Wed, 1 Jul 2026 12:00:00 -0700", "Subject: [PATCH] Subject", "", "Body", "", DIFF, ].join("\n"), ], ]; for (const [format, text] of formats) { assertEquals( buildView(text).editSource.isDiff, true, `${format} output retains commit-aware diff handling`, ); } }); Deno.test("buildView: CRLF commit-only output takes the commit branch", () => { const text = [ "commit 0123456789abcdef0123456789abcdef01234567", "Author: A B ", "Date: Wed Jul 1 12:00:00 2026 -0700", "", " Subject", "", ].join("\r\n"); assertEquals(buildView(text).editSource.isDiff, true); }); Deno.test("buildView: forceDiff pins diff mode even on non-diff text", () => { const r = buildView("const a = 1;\n", undefined, true); r.semantics(); assert(r.doc.lines.length > 0); }); Deno.test("buildView: forceDiff=false views a real diff as source (--no-diff)", () => { const diff = buildView(DIFF); // auto-detects: a diff assert( diff.doc.flatStructure.some((n) => n.kind === "hunk"), "auto-detect treats it as a diff", ); const source = buildView(DIFF, undefined, false); // --no-diff assert( !source.doc.flatStructure.some((n) => n.kind === "hunk"), "forceDiff=false suppresses diff detection and parses it as source", ); }); Deno.test("buildView: a source file that embeds a complete diff stays source", () => { const embedded = `const patch = \` diff --git a/x.ts b/x.ts --- a/x.ts +++ b/x.ts @@ -1 +1 @@ -old +new \`; `; const r = buildView(embedded, "fixture.ts"); r.semantics(); assertEquals(r.editSource.isDiff, undefined); assert( r.doc.flatStructure.some((node) => node.kind === "variable" && node.name === "patch" ), "the TypeScript source keeps its structure", ); }); Deno.test("buildView: a git header followed by ordinary source stays source", () => { const source = "diff --git a/x.ts b/x.ts\n" + "const value = 1;\n" + "export { value };\n"; const view = buildView(source); assertEquals(view.editSource.isDiff, undefined); assertEquals(view.doc.structure, []); assert( view.doc.lines.flatMap((line) => line.spans).every((span) => span.cls === "plain" ), "the unnamed input remains plain source", ); });