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
4 changes: 3 additions & 1 deletion src/app/components/blocks/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export function CodeBlock({ node, id }: { node: Extract<Node, { kind: 'code' }>;
const rawLang = node.lang && node.lang !== 'text' ? node.lang : undefined
// Unrendered mermaid keeps its 'mermaid' title but skips tree-sitter (no
// grammar for it) — render the raw source as plain highlighted text.
const filetype = rawLang && rawLang !== 'mermaid' ? infoStringToFiletype(rawLang) : undefined
const highlightLang = rawLang ?? node.inferredLang
const filetype =
highlightLang && highlightLang !== 'mermaid' ? infoStringToFiletype(highlightLang) : undefined
const title = rawLang ? ` ${rawLang} ` : undefined

const lines = node.value.split('\n')
Expand Down
10 changes: 10 additions & 0 deletions src/app/lib/ast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ describe('buildTree', () => {
const { nodes } = buildTree('```js\nx\n```')
expect(nodes[0]).toMatchObject({ kind: 'code', lang: 'js', value: 'x' })
})

test('untagged code block carries an inferred lang', () => {
const { nodes } = buildTree('```\n$ bun test\n```')
expect(nodes[0]).toMatchObject({ kind: 'code', lang: undefined, inferredLang: 'bash' })
})

test('tagged code block skips inference', () => {
const { nodes } = buildTree('```text\n$ bun test\n```')
expect(nodes[0]).toMatchObject({ kind: 'code', lang: 'text', inferredLang: undefined })
})
})

describe('buildTree — details wrapping', () => {
Expand Down
17 changes: 15 additions & 2 deletions src/app/lib/ast.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { marked } from 'marked'
import type { Tokens } from 'marked'
import { detectLang } from './detect-lang'
import { htmlContainsBlockMarkdown, htmlToMarkdown, stripHtml } from './html'

marked.use({
Expand Down Expand Up @@ -37,7 +38,13 @@ export type InlineNode =
export type Node =
| { kind: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; id: string; text: InlineNode[] }
| { kind: 'paragraph'; inline: InlineNode[] }
| { kind: 'code'; lang?: string; value: string }
| {
kind: 'code'
lang?: string
value: string
/** Guessed by `detectLang` when the fence has no info string; drives highlighting only, never the title. */
inferredLang?: string
}
| { kind: 'list'; ordered: boolean; start?: number; items: ListItem[] }
| { kind: 'blockquote'; children: Node[] }
| { kind: 'table'; header: InlineNode[][]; rows: InlineNode[][][] }
Expand Down Expand Up @@ -261,7 +268,13 @@ function blockToNode(t: Tokens.Generic, ctx: ParseContext): Node | null {
}
case 'code': {
const c = t as Tokens.Code
return { kind: 'code', lang: c.lang || undefined, value: c.text }
const lang = c.lang || undefined
return {
kind: 'code',
lang,
value: c.text,
inferredLang: lang ? undefined : detectLang(c.text),
}
}
case 'list': {
const l = t as Tokens.List
Expand Down
107 changes: 107 additions & 0 deletions src/app/lib/detect-lang.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, test } from 'bun:test'
import { detectLang } from './detect-lang'

describe('detectLang', () => {
describe('shebang', () => {
test.each([
['#!/bin/bash\necho hi', 'bash'],
['#!/bin/sh\necho hi', 'bash'],
['#!/usr/bin/env zsh\necho hi', 'bash'],
['#!/usr/bin/env python3\nprint(1)', 'python'],
['#!/usr/bin/python3.11\nprint(1)', 'python'],
['#!/usr/bin/env -S node --no-warnings\nconsole.log(1)', 'javascript'],
['#!/usr/bin/env bun\nconsole.log(1)', 'typescript'],
['#!/usr/bin/env FOO=1 deno\nconsole.log(1)', 'typescript'],
])('%p -> %p', (src, lang) => {
expect(detectLang(src)).toBe(lang)
})

test('unknown interpreter yields undefined', () => {
expect(detectLang('#!/usr/bin/env perl\nprint 1;')).toBeUndefined()
})
})

test('shell prompt on first line', () => {
expect(detectLang('$ bun install\n$ bun test')).toBe('bash')
expect(detectLang('\n$ ls\nfile.txt')).toBe('bash')
})

test('JSON object or array', () => {
expect(detectLang('{"a": 1, "b": [true, null]}')).toBe('json')
expect(detectLang('[\n 1,\n 2\n]')).toBe('json')
})

test('JSON-like but invalid stays undetected', () => {
expect(detectLang('{ a: 1 }')).toBeUndefined()
})

test.each([
'<!DOCTYPE html>\n<html></html>',
'<html lang="en">\n</html>',
'<div class="card">\n <p>Hi</p>\n</div>',
'<img src="logo.png" alt="Logo" width="200">',
'<p align="center">\n <a href="x"><img src="y"></a>\n</p>',
'\n<table>\n <tr><td>1</td></tr>\n</table>',
'<br/>',
'<!-- comment -->\n<span>x</span>',
])('HTML: %p', src => {
expect(detectLang(src)).toBe('html')
})

test.each([
['JSX expression attribute', '<div className={styles.card}>\n {children}\n</div>'],
['component tag', '<App />'],
['component named like a tag', '<Button>Click</Button>'],
['unknown tag', '<foo>bar</foo>'],
['tag-name prefix', '<abc>'],
['HTML after prose', 'Use this:\n<div></div>'],
])('not HTML: %s', (_, src) => {
expect(detectLang(src)).toBeUndefined()
})

test('Go package clause', () => {
expect(detectLang('// Package main\npackage main\n\nfunc main() {}')).toBe('go')
})

test('Java package clause is not Go', () => {
expect(detectLang('package com.example;')).toBeUndefined()
})

test.each([
'def foo(x):\n return x + 1',
'async def foo() -> None:\n pass',
'from os import path',
'class Foo(Base):\n pass',
])('Python: %p', src => {
expect(detectLang(src)).toBe('python')
})

test.each([
'fn main() {\n println!("hi");\n}',
'pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}',
'use std::collections::HashMap;',
])('Rust: %p', src => {
expect(detectLang(src)).toBe('rust')
})

test('Zig fn signature is not Rust', () => {
expect(detectLang('pub fn main() void {\n}')).toBeUndefined()
})

test('TOML with a table header', () => {
expect(detectLang('[package]\nname = "x"\nversion = "0.1.0"')).toBe('toml')
})

test('bare assignment without a table header stays undetected', () => {
expect(detectLang('x = 1')).toBeUndefined()
})

test.each([
['file tree', 'src/\n├── index.ts\n└── app/'],
['log output', 'ERROR 2024-01-01 failed to connect\nretrying in 5s...'],
['prose', 'This is just some plain text.'],
['empty', ''],
])('plain text stays undetected: %s', (_, src) => {
expect(detectLang(src)).toBeUndefined()
})
})
100 changes: 100 additions & 0 deletions src/app/lib/detect-lang.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Guess the language of an untagged fenced code block. Precision over recall:
* a rule fires only on syntax that is near-unique to one language, and
* anything else returns undefined so plain text (trees, logs, output) is not
* miscolored. Returned names are info strings `infoStringToFiletype` resolves
* to a bundled grammar.
*/
export function detectLang(value: string): string | undefined {
if (value.startsWith('#!')) return shebangLang(firstLine(value))
for (const rule of RULES) {
if (rule.test(value)) return rule.lang
}
return undefined
}

type Rule = { lang: string; test: (value: string) => boolean }

const RULES: Rule[] = [
{ lang: 'bash', test: v => /^\$ \S/.test(v.trimStart()) },
{ lang: 'json', test: isJson },
{ lang: 'html', test: isHtml },
// `package x;` (Java) has a semicolon and dotted path; Go's clause has neither.
{ lang: 'go', test: v => /^package [a-z_]\w*\s*$/m.test(v) },
{
lang: 'python',
test: v =>
/^(async )?def \w+\(.*\)(\s*->.*)?:\s*$/m.test(v) ||
/^from [\w.]+ import \w/m.test(v) ||
/^class \w+(\(.*\))?:\s*$/m.test(v),
},
{
lang: 'rust',
// Zig's `fn f() void {` has a bare return type, which this signature rejects.
test: v =>
/^(pub )?fn \w+(<.*>)?\(.*\)\s*(->.*)?\{?\s*$/m.test(v) ||
/^use \w+::[\w:{}, *]+;\s*$/m.test(v),
},
{
lang: 'toml',
test: v => /^\[[\w.-]+\]\s*$/m.test(v) && /^[\w.-]+\s*=\s*\S/m.test(v),
},
]

const INTERPRETER_LANGS: Record<string, string> = {
sh: 'bash',
bash: 'bash',
zsh: 'bash',
dash: 'bash',
ksh: 'bash',
python: 'python',
node: 'javascript',
bun: 'typescript',
deno: 'typescript',
tsx: 'typescript',
'ts-node': 'typescript',
}

/** Interpreter parsing follows GitHub Linguist: unwrap `env` flags and `K=V` pairs, drop version suffixes. */
function shebangLang(line: string): string | undefined {
const [path = '', ...args] = line.slice(2).trim().split(/\s+/)
let interpreter = basename(path)
if (interpreter === 'env') {
interpreter = args.find(a => !a.startsWith('-') && !a.includes('=')) ?? ''
}
const name = interpreter.replace(/\.\d+$/, '').replace(/\d+$/, '')
return INTERPRETER_LANGS[name]
}

const HTML_TAG_START =
/^(<!--|<(html|head|body|div|span|p|a|img|br|h[1-6]|ul|ol|li|table|script|style)[\s/>])/

/**
* The first line must open a known lowercase tag, which rules out components
* (`<App />`) and generics (`<T>`). `={` marks a JSX expression attribute.
*/
function isHtml(value: string): boolean {
const start = value.trimStart()
if (/^<!doctype html/i.test(start)) return true
return HTML_TAG_START.test(start) && !value.includes('={')
}

function isJson(value: string): boolean {
const trimmed = value.trim()
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false
try {
JSON.parse(trimmed)
return true
} catch {
return false
}
}

function firstLine(value: string): string {
const end = value.indexOf('\n')
return end === -1 ? value : value.slice(0, end)
}

function basename(path: string): string {
return path.slice(path.lastIndexOf('/') + 1)
}
4 changes: 3 additions & 1 deletion src/app/lib/renderAnsi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,9 @@ async function preloadParsersForNodes(nodes: Node[]): Promise<void> {

function collectLangs(nodes: Node[], out: Set<string>): void {
for (const node of nodes) {
if (node.kind === 'code' && node.lang) out.add(node.lang)
if (node.kind !== 'code') continue
const lang = node.lang ?? node.inferredLang
if (lang) out.add(lang)
}
}

Expand Down
Loading
Loading