From 01d614a5d90b351d3a7c67a2a4a318371e21adaf Mon Sep 17 00:00:00 2001 From: Chris Wanstrath <2+defunkt@users.noreply.github.com> Date: Sun, 21 Sep 2025 12:54:54 -0700 Subject: [PATCH] cat --- nose/bin/cat.ts | 106 +++++++++++++++++++++++++++++++ nose/lib/highlight.ts | 144 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 nose/bin/cat.ts create mode 100644 nose/lib/highlight.ts diff --git a/nose/bin/cat.ts b/nose/bin/cat.ts new file mode 100644 index 0000000..ccba640 --- /dev/null +++ b/nose/bin/cat.ts @@ -0,0 +1,106 @@ +import { escapeHTML } from "bun" +import { readdirSync } from "fs" +import { join, extname } from "path" + +import type { CommandOutput } from "@/shared/types" +import { NOSE_WWW } from "@/config" +import { getState } from "@/state" +import { appPath } from "@/webapp" +import { highlight } from "../lib/highlight" + +export default async function (path: string) { + const state = getState() + if (!state) return { error: "no state" } + + const project = state.project + if (!project) return { error: "no project loaded" } + + const root = appPath(project) + if (!root) return { error: "error loading project" } + + let files: string[] = [] + + for (const file of readdirSync(root, { withFileTypes: true })) { + files.push(file.name) + } + + if (root === NOSE_WWW) { + files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`)) + } + + if (!files.includes(path)) + return { error: `file not found: ${path}` } + + return await readFile(join(root, path)) +} + +async function readFile(path: string): Promise { + const ext = extname(path).slice(1) + const file = Bun.file(path) + + switch (ext) { + case "jpeg": case "jpg": case "png": case "gif": case "webp": + const img = await file.arrayBuffer() + return { html: `` } + case "mp3": case "wav": + case "mp4": case "mov": case "avi": case "mkv": case "webm": + return "Not implemented" + case "js": case "ts": case "tsx": case "json": + return { + html: `
${highlight(convertIndent(await file.text()))}
` + } + default: + if (await isBinaryContent(file)) + throw "Cannot display binary file" + + return { + html: `
${escapeHTML(await file.text())}
` + } + } +} + +// cut down 4space and 2space indents into 1space, for readability on a small screen +function convertIndent(str: string) { + const lines = str.split("\n") + + // find the smallest non-zero indent + const minIndent = Math.min( + ...lines + .map(l => (l.match(/^ +/) || [""])[0].length) + .filter(len => len > 0) + ) + + return lines + .map(line => + line.replace(/^( +)/, (_, spaces) => " ".repeat(spaces.length / minIndent)) + ) + .join("\n") +} + + + +async function isBinaryContent(file: Bun.FileBlob): Promise { + // Create a stream to read just the beginning + const stream = file.stream() + const reader = stream.getReader() + + try { + // Read first chunk (typically 16KB, which is more than enough to detect binary) + const { value } = await reader.read() + if (!value) return false + + // Check first 512 bytes or less + const bytes = new Uint8Array(value) + const checkLength = Math.min(bytes.length, 512) + for (let i = 0; i < checkLength; i++) { + const byte = bytes[i]! + if (byte === 0) return true // null byte + if (byte < 32 && ![9, 10, 13].includes(byte)) return true // control char + } + + return false + } finally { + reader.releaseLock() + stream.cancel() + } +} diff --git a/nose/lib/highlight.ts b/nose/lib/highlight.ts new file mode 100644 index 0000000..cc56d30 --- /dev/null +++ b/nose/lib/highlight.ts @@ -0,0 +1,144 @@ +// simple-ts-highlighter.ts — regex-only, self-hostable + +export type TokenType = + | "string" | "number" | "keyword" | "boolean" | "null" | "undefined" + | "comment" | "identifier" | "punctuation" | "whitespace" | "unknown" + +export type Token = { type: TokenType; value: string; start: number; end: number } +export type Program = { type: "Program"; tokens: Token[] } + +const RE = { + // regex literal: /.../flags (handles escapes and [...] classes; still simple) + regex: /^\/(?![/*])(?:\\.|\[(?:\\.|[^\]\\])*\]|[^\\/\n\r])+\/[a-zA-Z]*/, + + // comments + lineComment: /^\/\/[^\n\r]*/, + blockComment: /^\/\*[\s\S]*?\*\//, + + // strings + sng: /^'(?:\\.|[^'\\])*'/, + dbl: /^"(?:\\.|[^"\\])*"/, + bkt: /^`(?:\\.|[^`\\])*`/, + + // numbers + number: /^(?:0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+|\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/, + + // literals + boolNullUndef: /^(?:true|false|null|undefined)\b/, + + // keywords + keywords: /^(?:async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|function|if|import|in|instanceof|let|new|return|super|switch|this|throw|try|typeof|var|void|while|with|yield|as|implements|interface|package|private|protected|public|readonly|abstract|declare|type|from|of)\b/, + + // identifier / punct / whitespace + ident: /^[A-Za-z_$][A-Za-z0-9_$]*/, + punct: /^[()[\]{}.,;:?~!%^&*+\-=/|<>]+/, + ws: /^\s+/, +} + +const types = ["string", "number", "boolean", "any", "void"] + +export function highlight(code: string): string { + const tokens = tokenize(code).tokens + return `` + tokens.map(t => tokenToHTML(t)).join("") +} + +export function tokenize(src: string): Program { + const tokens: Token[] = [] + let i = 0 + + const eat = (re: RegExp): string | null => { + const m = re.exec(src.slice(i)) + return m ? m[0] : null + } + + while (i < src.length) { + let v: string | null + + // If current char is '/', disambiguate regex/comment upfront + if (src[i] === "/") { + if (src[i + 1] === "/") { + v = eat(RE.lineComment) + if (v) { tokens.push({ type: "comment", value: v, start: i, end: i + v.length }); i += v.length; continue } + } else if (src[i + 1] === "*") { + v = eat(RE.blockComment) + if (v) { tokens.push({ type: "comment", value: v, start: i, end: i + v.length }); i += v.length; continue } + } else if ((v = eat(RE.regex))) { + // Treat regex literal as a "string" for your minimal category set + tokens.push({ type: "string", value: v, start: i, end: i + v.length }); i += v.length; continue + } + } + + // Strings + if ((v = eat(RE.sng) || eat(RE.dbl) || eat(RE.bkt))) { + tokens.push({ type: "string", value: v, start: i, end: i + v.length }); i += v.length; continue + } + // Numbers + if ((v = eat(RE.number))) { + tokens.push({ type: "number", value: v, start: i, end: i + v.length }); i += v.length; continue + } + // true/false/null/undefined + if ((v = eat(RE.boolNullUndef))) { + const t: TokenType = v === "true" || v === "false" ? "boolean" : (v === "null" ? "null" : "undefined") + tokens.push({ type: t, value: v, start: i, end: i + v.length }); i += v.length; continue + } + // Keywords + if ((v = eat(RE.keywords))) { + tokens.push({ type: "keyword", value: v, start: i, end: i + v.length }); i += v.length; continue + } + // Identifiers + if ((v = eat(RE.ident))) { + tokens.push({ type: "identifier", value: v, start: i, end: i + v.length }); i += v.length; continue + } + // Punctuation / operators + if ((v = eat(RE.punct))) { + tokens.push({ type: "punctuation", value: v, start: i, end: i + v.length }); i += v.length; continue + } + // Whitespace + if ((v = eat(RE.ws))) { + tokens.push({ type: "whitespace", value: v, start: i, end: i + v.length }); i += v.length; continue + } + + // Fallback + if (src[i]) { + tokens.push({ type: "unknown", value: src[i]!, start: i, end: i + 1 }) + } + i += 1 + } + + return { type: "Program", tokens } +} + +function tokenToHTML(token: Token): string { + switch (token.type) { + case "string": return `${escapeHtml(token.value)}` + case "number": return `${token.value}` + case "keyword": return `${token.value}` + case "comment": return `${escapeHtml(token.value)}` + case "null": case "undefined": case "boolean": + return `${token.value}` + case "punctuation": { + // if (token.value === "(" || token.value === ")" || token.value === "{" || token.value === "}" || token.value === "[" || token.value === "]") + // return `${token.value}` + // else + return escapeHtml(token.value) + } + case "identifier": { + if (token.value[0]?.match(/[A-Z]/) || types.includes(token.value)) + return `${token.value}` + else + return `${token.value}` + } + case "whitespace": + case "unknown": + return `${escapeHtml(token.value)}` + } +} + +export function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} \ No newline at end of file