diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 989d22a711..aece48f580 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -230,13 +230,22 @@ const LANG_ALIASES = new Map([ ['gradle', 'groovy'], ]) +/** + * Resolve a language hint to the grammar id {@link LANG_ALIASES} selects. + * @param lang - Language hint from a code surface: a shared canonical id or a legacy read-card hint. + * @returns The resolved grammar id, or `undefined` when the table aliases no grammar. + */ +export function grammarForHint(lang: string | undefined): string | undefined { + return lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase()) +} + /** * Whether a language hint can use the shared syntax highlighter. * @param lang - Language hint from a code surface. * @returns Whether the hint resolves to a supported grammar. */ export function supportsHighlighting(lang: string | undefined): boolean { - return lang !== undefined && LANG_ALIASES.has(lang.toLowerCase()) + return grammarForHint(lang) !== undefined } /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */ diff --git a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx index c8fdde9199..eb8c398992 100644 --- a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, renderHook, waitFor } from '@testing-library/react' -import { languageForPath as sharedLanguageForPath } from '@deepseek-ai/dsh-util-code-language' +import { languageForPath as sharedLanguageForPath, readLangHintForPath } from '@deepseek-ai/dsh-util-code-language' import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, useCodeHighlighter } from '../src/code-highlighting.ts' -import { supportsHighlighting } from '../src/markdown/highlight.ts' +import { grammarForHint, supportsHighlighting } from '../src/markdown/highlight.ts' afterEach(cleanup) @@ -28,7 +28,7 @@ describe('code highlighting', () => { it.each([ ['build.bat', 'bat'], ['build.cmd', 'bat'], ['deploy.ps1', 'powershell'], ['module.psm1', 'powershell'], ['config.fish', 'fish'], ['app.properties', 'ini'], - ['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'], ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], ['notebook.ipynb', 'json'], ['guide.rst', 'rst'], ['paper.tex', 'latex'], ['style.sty', 'latex'], ['doc.cls', 'latex'], ['refs.bib', 'bibtex'], ['Info.plist', 'xml'], ['logo.svg', 'xml'], ['manual.adoc', 'asciidoc'], @@ -46,11 +46,25 @@ describe('code highlighting', () => { }) it('resolves through the one shared extension table, never a local copy', () => { - // A second table re-introduced here fails this identity assertion before it - // can drift from the read card's file-extension hints. + // A second canonical table re-introduced here fails this identity assertion + // before it can drift from the shared extension table. expect(languageForPath).toBe(sharedLanguageForPath) }) + it('reaches one grammar from the shared canonical id and the read card legacy hint', () => { + // The read card persists the old short ids while this surface reads the + // canonical table directly; both must select the same grammar, so a + // persisted hint can never render differently from this surface's own + // selection. This is the alias-normalized check the identity assertion + // cannot make now that the read projection intentionally differs. + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + const path = `file.${extension}` + const readHint = readLangHintForPath(path) + expect(readHint, extension).toBeDefined() + expect(grammarForHint(readHint), extension).toBe(grammarForHint(languageForPath(path))) + } + }) + it('keeps every registered suffix highlightable by the shared primitive', () => { expect(new Set(CODE_HIGHLIGHT_EXTENSIONS).size).toBe(CODE_HIGHLIGHT_EXTENSIONS.length) for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index 79963d3c42..669c85818c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -77,7 +77,7 @@ async function boot() { const fiber = ctx.plugin({ inject: [...inject], apply }) onTestFinished(async () => { await fiber.dispose() }) await fiber.await() - return { tabs, registered, dictionaries, fiber, read, readBytes } + return { ctx, tabs, registered, dictionaries, fiber, read, readBytes } } describe('ui-sidebar-documentpreview apply', () => { @@ -114,6 +114,36 @@ describe('ui-sidebar-documentpreview apply', () => { expect(dictionaries.size).toBe(0) }) + it('leaves every declared suffix with its owning preview and never lets the shared highlighter claim a later viewer\'s suffix', async () => { + const { ctx } = await boot() + const previews = ctx.get('documentPreviews') + if (previews === undefined) throw new Error('documentPreviews was not provided') + // apply() registers in production order: text, markdown, html, image, pdf, + // code, office, excel. The shared Code body is the generic highlighter, so + // where it collides with an EARLIER dedicated body the earlier body keeps the + // suffix; any other collision means a later dedicated viewer lost a suffix it + // must render. Listing csv under Code makes Spreadsheet the loser. + const sharedHighlighter = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code' + const owners = new Map() + for (const definition of previews.getSnapshot()) { + for (const extension of new Set([...definition.extensions, ...definition.binaryExtensions ?? []])) { + const winner = previews.candidates(`file.${extension}`)[0] + const earlier = owners.get(extension) + if (earlier === undefined) { + expect(winner?.id, `${definition.id} declares .${extension}`).toBe(definition.id) + } else { + expect(winner?.id, `.${extension} must stay with the earlier ${earlier}`).toBe(earlier) + expect(definition.id, `later body ${definition.id} must not collide on .${extension}`).toBe(sharedHighlighter) + } + owners.set(extension, earlier ?? definition.id) + } + } + // Delimited spreadsheets stay with the Spreadsheet viewer, not the Code body. + const excel = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel' + expect(previews.candidates('table.csv')[0]?.id).toBe(excel) + expect(previews.candidates('table.tsv')[0]?.id).toBe(excel) + }) + it('injects paged and byte Remote reads independently of resource metadata', async () => { const { registered, read, readBytes } = await boot() const registration = registered.find(entry => entry.component === TextPreview) diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 7862a4366f..79301214ad 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -170,14 +170,17 @@ ${body} } /** - * Derive a syntax-highlighting language id from a read path's file extension via - * the extension table shared with the Client code surfaces; see - * `@deepseek-ai/dsh-util-code-language` for the table and the path rules (both - * separators, a leading dot as the extension separator, and prototype-key safety). + * Derive the persisted `lang` hint from a read path's file extension. The shared + * table in `@deepseek-ai/dsh-util-code-language` owns the recognized suffixes and + * the path rules (both separators, a leading dot as the extension separator, and + * prototype-key safety); `readLangHintForPath` projects the read card's + * historical short ids over it, so a suffix the pre-unification read table + * recognized keeps its byte-identical old value while a newly added suffix uses + * the canonical id. * @param path - the model-facing path the read reported. - * @returns the language id, or `undefined` when the extension maps to none. + * @returns the persisted language hint, or `undefined` when the extension maps to none. */ -export { languageForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language' +export { readLangHintForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language' /** * The `read` tool's private `tool/result` `meta` payload: the structured diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index 2e364b8e74..14a22083c0 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { languageForPath } from '@deepseek-ai/dsh-util-code-language' +import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '@deepseek-ai/dsh-util-code-language' import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' import type { ReadWindow } from '../src/read-render.ts' @@ -118,30 +118,77 @@ describe('buildWindow', () => { }) }) +/** + * The pre-unification read table's extension-to-hint mapping, transcribed + * verbatim. A read card persisted one of these strings, so changing any value + * changes replay-visible output for an already-recorded session. + */ +const MASTER_READ_LANG_BY_EXTENSION: Readonly> = { + ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', + js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', + json: 'json', jsonc: 'json', + py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', + c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', + cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', + sh: 'sh', bash: 'sh', zsh: 'sh', + yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', + md: 'md', markdown: 'md', mdx: 'mdx', + html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', + sql: 'sql', xml: 'xml', lua: 'lua', +} + describe('langFromPath', () => { - it('resolves through the one shared extension table, never a local copy', () => { - // The read card and the Client code surfaces must agree on every path; a - // local table re-introduced on either side makes one of these identity - // assertions fail before the two can drift in language hints. - expect(langFromPath).toBe(languageForPath) - for (const path of ['src/a.ts', 'conf.yml', 'README.md', 'build.PS1', 'C:\\src\\main.rs']) { - expect(langFromPath(path), path).toBe(languageForPath(path)) + it('keeps every pre-unification suffix byte-identical to the old persisted hint', () => { + expect(Object.keys(MASTER_READ_LANG_BY_EXTENSION)).toHaveLength(43) + for (const [extension, hint] of Object.entries(MASTER_READ_LANG_BY_EXTENSION)) { + expect(langFromPath(`file.${extension}`), extension).toBe(hint) } }) - it('maps a known extension to its canonical language id, case-insensitively', () => { - expect(langFromPath('src/a.ts')).toBe('typescript') - expect(langFromPath('src/a.TSX')).toBe('typescript') - expect(langFromPath('/abs/module.mjs')).toBe('javascript') + it('uses the shared canonical id for every suffix the old read table did not know', () => { + // The legacy projection is keyed by exactly the old extensions, so a suffix + // the shared table adds later must fall through to its canonical id instead + // of inheriting a stale short hint. + const legacy = new Set(Object.keys(MASTER_READ_LANG_BY_EXTENSION)) + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + if (legacy.has(extension)) continue + expect(langFromPath(`file.${extension}`), extension).toBe(languageForPath(`file.${extension}`)) + } + }) + + it('gives a newly added suffix its shared canonical id', () => { + // These suffixes were undefined in the old build, so they are free to carry + // the canonical grammar id instead of a short hint. + expect(langFromPath('build.ps1')).toBe('powershell') + expect(langFromPath('deploy.bat')).toBe('bat') + expect(langFromPath('.env')).toBe('dotenv') + expect(langFromPath('server.log')).toBe('log') + expect(langFromPath('message.proto')).toBe('proto') + expect(langFromPath('infra.tf')).toBe('hcl') + expect(langFromPath('paper.tex')).toBe('latex') + }) + + it('maps a known extension to its old short hint, case-insensitively', () => { + expect(langFromPath('src/a.ts')).toBe('ts') + expect(langFromPath('src/a.TSX')).toBe('tsx') + expect(langFromPath('/abs/module.mjs')).toBe('js') expect(langFromPath('conf.yml')).toBe('yaml') - expect(langFromPath('README.md')).toBe('markdown') + expect(langFromPath('README.md')).toBe('md') + }) + + it('keeps the canonical ids the Client code surfaces use out of the persisted hint', () => { + // The Client reads the shared table directly; only this projection owns the + // persisted value, so the two intentionally differ for the old suffixes. + expect(languageForPath('src/a.ts')).toBe('typescript') + expect(languageForPath('README.md')).toBe('markdown') + expect(langFromPath('src/a.ts')).not.toBe(languageForPath('src/a.ts')) }) it('reads the extension after the last path segment and last dot', () => { expect(langFromPath('a.py.bak')).toBeUndefined() expect(langFromPath('archive.tar.gz')).toBeUndefined() expect(langFromPath('/dir.py/plain')).toBeUndefined() - expect(langFromPath('C:\\src\\main.rs')).toBe('rust') + expect(langFromPath('C:\\src\\main.rs')).toBe('rs') }) it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index ac70df7015..28613a2920 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -352,7 +352,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'typescript', + lang: 'ts', }) const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result) expect(view).toEqual({ @@ -361,7 +361,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'typescript', + lang: 'ts', content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }], }) }) diff --git a/packages/util/code-language/README.i18n.yaml b/packages/util/code-language/README.i18n.yaml index e9789fe208..0c35649bcd 100644 --- a/packages/util/code-language/README.i18n.yaml +++ b/packages/util/code-language/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/code-language/README.md -README.md: a65c28cc11e5aa112b5450b9ec42b23dce764c9d -README.zh.md: cc15751853a8ab8b796bf16f989ee001d7347d9e +README.md: adb04ba3283e38d1b708f1831ffd41c0f08ce967 +README.zh.md: c2ced098f48946abdc4affae6b7b3fa09bd439fb diff --git a/packages/util/code-language/README.md b/packages/util/code-language/README.md index a65c28cc11..adb04ba328 100644 --- a/packages/util/code-language/README.md +++ b/packages/util/code-language/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a filename or path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the extension separator (`.env` resolves to `dotenv`). `CODE_HIGHLIGHT_EXTENSIONS` lists every recognized suffix for a preview registry. The package is browser-safe, has no Cordis service or runtime state, and leaves the actual tokenization to the Client highlighter. +The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the separator (`.env` is `dotenv`); `CODE_HIGHLIGHT_EXTENSIONS` lists every suffix for a preview registry. `readLangHintForPath` projects the read card's older short ids over the table so previously persisted values stay byte-identical. The package is browser-safe, stateless, and leaves tokenization to the Client highlighter. ## Table of Contents @@ -24,7 +24,7 @@ The repository's one file-extension to syntax-highlighting language table, share The table lives in [`src/index.ts`](src/index.ts). Each key is a canonical language id — the grammar id the Client highlighter resolves — and each value is the lowercase extensions without the dot that select it. `languageForPath(path)` takes the text after the last dot of the final path segment, lowercases it, and looks it up in a `Map`; a `Map` keeps a filename such as `foo.constructor` from resolving an `Object.prototype` member. An unlisted dotfile (`.gitignore`), an absent extension, a trailing dot, and an unknown suffix all return `undefined`, which every consumer renders as plain text; a leading dot is still the separator, so `.env` resolves to `dotenv`. Both `/` and `\` separate path segments, so a Windows path resolves like a POSIX one. -The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. +The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted, and `csv` stays unlisted because the Spreadsheet preview declares the suffix and must keep it, so the earlier Code registration cannot claim it. `readLangHintForPath` is a projection over this table: a suffix the pre-unification read table recognized returns its old short id, a suffix present only here returns its canonical id, and an unrecognized suffix returns `undefined`. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. ----- diff --git a/packages/util/code-language/README.zh.md b/packages/util/code-language/README.zh.md index cc15751853..c2ced098f4 100644 --- a/packages/util/code-language/README.zh.md +++ b/packages/util/code-language/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 +本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。Host read 卡片保留统一前的短 id:`readLangHintForPath` 在同一张表上做投影——旧 read 表已识别的后缀保持逐字节相同的旧值,新增后缀则使用规范 id。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 ## 目录 @@ -24,7 +24,7 @@ kind: "package-library" 表位于 [`src/index.ts`](src/index.ts)。每个键是规范化语言 id——即 Client 高亮器解析的 grammar id;每个值是选中它的、不含点的小写扩展名。`languageForPath(path)` 取路径最后一段的最后一个点之后的文本、转小写后在 `Map` 中查找;使用 `Map` 可避免 `foo.constructor` 这类文件名解析到 `Object.prototype` 成员。未列入表的 dotfile(`.gitignore`)、无扩展名、结尾点、未知后缀都返回 `undefined`,各消费方一律按纯文本渲染;前导点仍算分隔符,因此 `.env` 解析为 `dotenv`。`/` 与 `\` 都算路径分隔符,因此 Windows 路径与 POSIX 路径解析一致。 -该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 +该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表,`csv` 同样保持不表:Spreadsheet 预览声明该后缀且必须保留,因此更早注册的 Code 不能抢占它。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 ----- diff --git a/packages/util/code-language/src/index.ts b/packages/util/code-language/src/index.ts index 09aac2143f..1f7743fee2 100644 --- a/packages/util/code-language/src/index.ts +++ b/packages/util/code-language/src/index.ts @@ -5,6 +5,9 @@ * highlighter's alias table resolves, so a language returned here reaches * `highlightLines`/`highlightToHtml` unchanged; a filename outside the table, or * one naming a language the highlighter does not register, renders as plain text. + * The read card predates the shared table, so {@link readLangHintForPath} projects + * its historical short ids over this table for the suffixes the old read table + * already recognized, keeping those persisted values byte-identical. * @module @deepseek-ai/dsh-util-code-language */ @@ -17,7 +20,9 @@ * grammar does not exist map to the nearest available grammar * (`properties` to `ini`, whose registration carries the `properties` alias) or * stay unlisted. Certificate and lock extensions (`pem`, `crt`, `key`, `cer`, - * `lock`) intentionally stay unlisted. + * `lock`) stay unlisted, and `csv` is deliberately absent: the Spreadsheet + * preview declares it and must keep it, so listing it here would let the earlier + * Code registration take the suffix. */ const LANGUAGE_EXTENSIONS: Readonly> = { typescript: ['ts', 'tsx', 'mts', 'cts'], @@ -41,7 +46,6 @@ const LANGUAGE_EXTENSIONS: Readonly> = { ini: ['ini', 'conf', 'cfg', 'properties'], dotenv: ['env'], log: ['log'], - csv: ['csv'], diff: ['diff', 'patch'], http: ['http'], markdown: ['md', 'markdown'], @@ -95,6 +99,34 @@ const LANGUAGES = new Map(Object.entries(LANGUAGE_EXTENSIONS) /** Every recognized filename suffix, one entry each; a document-preview registry uses it to claim Code-rendered bodies. */ export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()] +/** + * The read card's historical short language hints for the suffixes its + * pre-unification table recognized. Keys are shared-table extensions, so the + * recognized set still derives from {@link LANGUAGE_EXTENSIONS}; values preserve + * the old persisted `lang` strings byte for byte. An extension absent here but + * present in the shared table is new and uses its canonical id. + */ +const READ_LANG_BY_EXTENSION = new Map(Object.entries({ + ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', + js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', + json: 'json', jsonc: 'json', + py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', + c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', + cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', + sh: 'sh', bash: 'sh', zsh: 'sh', + yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', + md: 'md', markdown: 'md', mdx: 'mdx', + html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', + sql: 'sql', xml: 'xml', lua: 'lua', +})) + +function extensionForPath(path: string): string | undefined { + const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + const base = path.slice(slash + 1) + const dot = base.lastIndexOf('.') + return dot < 0 ? undefined : base.slice(dot + 1).toLowerCase() +} + /** * Derive the syntax-highlighting language from a filename or path, case * insensitively. The extension is the text after the last dot of the final path @@ -106,9 +138,23 @@ export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys() * @returns the canonical language id, or `undefined` for an unrecognized or absent suffix. */ export function languageForPath(path: string): string | undefined { - const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - const base = path.slice(slash + 1) - const dot = base.lastIndexOf('.') - if (dot < 0) return undefined - return LANGUAGES.get(base.slice(dot + 1).toLowerCase()) + const extension = extensionForPath(path) + return extension === undefined ? undefined : LANGUAGES.get(extension) +} + +/** + * Derive the Host read card's persisted `lang` hint from a read path's + * extension. This is the legacy projection the read card writes: a suffix the + * pre-unification read table recognized keeps its old short id (`ts`, `md`, + * `cpp`, …), while a suffix only the shared table knows (`.ps1`, `.env`, + * `.tf`, …) yields its canonical id. An unrecognized suffix stays + * `undefined`, so the card renders as plain text. + * @param path - the model-facing path the read reported. + * @returns the persisted language hint, or `undefined` when the extension maps to none. + */ +export function readLangHintForPath(path: string): string | undefined { + const extension = extensionForPath(path) + if (extension === undefined) return undefined + const canonical = LANGUAGES.get(extension) + return canonical === undefined ? undefined : READ_LANG_BY_EXTENSION.get(extension) ?? canonical } diff --git a/packages/util/code-language/tests/code-language.spec.ts b/packages/util/code-language/tests/code-language.spec.ts index 0ede43e840..f93681350e 100644 --- a/packages/util/code-language/tests/code-language.spec.ts +++ b/packages/util/code-language/tests/code-language.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '../src/index.ts' +import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, readLangHintForPath } from '../src/index.ts' describe('languageForPath', () => { it.each([ @@ -8,7 +8,7 @@ describe('languageForPath', () => { ['module.psm1', 'powershell'], ['data.psd1', 'powershell'], ['config.fish', 'fish'], // Config, data, and text extensions. ['app.properties', 'ini'], ['app.conf', 'ini'], ['app.cfg', 'ini'], - ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['.env', 'dotenv'], ['server.log', 'log'], ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], ['notebook.ipynb', 'json'], // Documentation and markup extensions. @@ -79,6 +79,51 @@ describe('languageForPath', () => { expect(languageForPath(`secret.${extension}`), extension).toBeUndefined() } }) + + it('leaves delimited-spreadsheet suffixes to the Spreadsheet preview', () => { + // The Code preview registers before the Spreadsheet preview, so listing csv + // here would make Code the earlier candidate and steal the suffix from the + // viewer that must render it. + for (const extension of ['csv', 'tsv']) { + expect(languageForPath(`table.${extension}`), extension).toBeUndefined() + } + }) +}) + +describe('readLangHintForPath', () => { + it('keeps the read card pre-unification short hints for the old suffixes', () => { + expect(readLangHintForPath('src/a.ts')).toBe('ts') + expect(readLangHintForPath('src/a.tsx')).toBe('tsx') + expect(readLangHintForPath('src/a.mts')).toBe('ts') + expect(readLangHintForPath('src/a.cts')).toBe('ts') + expect(readLangHintForPath('src/a.js')).toBe('js') + expect(readLangHintForPath('src/a.jsx')).toBe('jsx') + expect(readLangHintForPath('src/a.mjs')).toBe('js') + expect(readLangHintForPath('src/a.cjs')).toBe('js') + expect(readLangHintForPath('src/a.jsonc')).toBe('json') + expect(readLangHintForPath('src/a.cc')).toBe('cpp') + expect(readLangHintForPath('src/a.hpp')).toBe('cpp') + expect(readLangHintForPath('src/a.sh')).toBe('sh') + expect(readLangHintForPath('src/a.kt')).toBe('kotlin') + expect(readLangHintForPath('README.md')).toBe('md') + expect(readLangHintForPath('README.markdown')).toBe('md') + expect(readLangHintForPath('page.htm')).toBe('html') + }) + + it('uses the canonical id for a suffix added after the old read table', () => { + expect(readLangHintForPath('build.ps1')).toBe('powershell') + expect(readLangHintForPath('.env')).toBe('dotenv') + expect(readLangHintForPath('infra.tf')).toBe('hcl') + expect(readLangHintForPath('paper.tex')).toBe('latex') + expect(readLangHintForPath('server.log')).toBe('log') + expect(readLangHintForPath('message.proto')).toBe('proto') + }) + + it('returns undefined exactly when the shared table does not recognize the suffix', () => { + for (const path of ['.gitignore', '/etc/hosts', 'trailingdot.', 'data.unknownext', 'foo.constructor']) { + expect(readLangHintForPath(path), path).toBeUndefined() + } + }) }) describe('CODE_HIGHLIGHT_EXTENSIONS', () => {