Files
cinny/src/app/utils/looksLikeCode.ts
T
Lotus CIandClaude Opus 5.5 75d55e861b
CI / Build & Quality Checks (push) Successful in 1m52s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 10m45s
feat(composer): offer to format pasted code as a code block (#107)
When a multi-line paste looks like code, a chip above the composer asks
"That looks like code. Format it as a code block (js)?" with Format as code /
No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and
Settings → Editor → "Offer to Format Pasted Code" turns it off.

- utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation
  with depth changes, statement terminators, operators + brackets, keywords,
  monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted
  replies, URL lists, long sentence-punctuated lines, plain-word lines,
  markdown lists. Language guess only when fairly sure (js/ts/python/sql/
  rust/c/php).
- Accept rebuilds the pasted paragraphs as one code block from their plain
  text (code lines hold text only) and leaves the caret after it.
- Code blocks carry an optional lang → <code class="language-js"> (whitelisted
  identifier only).

Verified in Chromium: pasting a JS function shows the chip; Format as code →
sent formatted_body is <pre><code class="language-js"> with indentation intact;
a three-line prose paste shows no chip; typing after a paste dismisses it.
Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 11:05:49 -04:00

112 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* [Gitea #107] Decide whether a multi-line paste looks like source code, so the
* composer can OFFER to turn it into a code block (never converts on its own).
* Pure and unit-tested; tuned to keep prose, lists, quotes and URL lists out.
*/
export type CodeGuess = {
/** Offer "Paste as code block?" */
isCode: boolean;
/** Language for `class="language-…"`, only when fairly sure. */
lang?: string;
};
const KEYWORDS = [
/\bfunction\b/,
/\bconst\b/,
/\blet\b/,
/\bvar\b/,
/\breturn\b/,
/\bimport\b/,
/\bexport\b/,
/\bclass\b/,
/\bdef\b/,
/\bfn\b/,
/\bpub\b/,
/\bstruct\b/,
/\bif \(/,
/\bfor \(/,
/\bwhile \(/,
/#include\b/,
/\bSELECT\b/i,
/\bFROM\b/i,
/<\?php/,
/<\//,
];
const MONO_HTML = /font-family:[^;"]*(mono|courier|consolas|menlo)|<pre[\s>]|<code[\s>]/i;
const indentOf = (line: string): number => {
const m = /^[ \t]*/.exec(line);
return m ? m[0].replace(/\t/g, ' ').length : 0;
};
export function guessLanguage(text: string): string | undefined {
if (
/^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE)\b/im.test(text) &&
/\bFROM\b|\bINTO\b|\bSET\b|\(/i.test(text)
)
return 'sql';
if (/\bpub fn\b|\bfn \w+\(.*\)\s*(->|\{)|\blet mut\b/.test(text)) return 'rust';
if (/^\s*#include\s*[<"]/m.test(text)) return 'c';
if (
/^\s*def \w+\(.*\):\s*$/m.test(text) ||
(/^\s*(from \w+ )?import \w+\s*$/m.test(text) && /:\s*$/m.test(text))
)
return 'python';
if (/:\s*(string|number|boolean)\b|\binterface \w+|\btype \w+ =/.test(text) && /[{;]/.test(text))
return 'ts';
if (/\b(const|let|var)\b|=>/.test(text) && /[{;]/.test(text)) return 'js';
if (/^\s*<\?php/m.test(text)) return 'php';
return undefined;
}
export function looksLikeCode(text: string, html?: string): CodeGuess {
const lines = text.replace(/\r\n?/g, '\n').split('\n');
const nonEmpty = lines.filter((l) => l.trim() !== '');
if (nonEmpty.length < 3) return { isCode: false };
const n = nonEmpty.length;
const share = (pred: (l: string) => boolean) => nonEmpty.filter(pred).length / n;
// ── Prose guards ──
if (share((l) => /^\s*>/.test(l)) > 0.5) return { isCode: false }; // quoted reply
if (nonEmpty.every((l) => /^\s*(<)?https?:\/\/\S+(>)?\s*$/.test(l))) return { isCode: false };
const avgLen = nonEmpty.reduce((sum, l) => sum + l.length, 0) / n;
if (avgLen > 90 && share((l) => (l.match(/[.!?] /g) ?? []).length >= 2) > 0.5) {
return { isCode: false };
}
if (share((l) => /^[\p{L}\p{N}\s'’",.!?-]*$/u.test(l)) > 0.6) return { isCode: false };
if (share((l) => /^\s*([-*+]|\d+[.)])\s+\S/.test(l)) > 0.6) return { isCode: false }; // list
// ── Signals: need two ──
let signals = 0;
const indented = share((l) => /^( {2,}|\t)/.test(l));
let depthChanges = 0;
for (let i = 1; i < nonEmpty.length; i += 1) {
if (indentOf(nonEmpty[i]) !== indentOf(nonEmpty[i - 1])) depthChanges += 1;
}
if (indented >= 0.4 && depthChanges >= 2) signals += 1;
if (share((l) => /[;{}):,]\s*$/.test(l)) >= 0.3) signals += 1;
if (
nonEmpty.filter((l) => /=>|->|::|\(\)|[^=!<>]=[^=]/.test(l)).length >= 2 &&
/[{}[\]]/.test(text)
) {
signals += 1;
}
if (KEYWORDS.filter((k) => k.test(text)).length >= 2) signals += 1;
if (html && MONO_HTML.test(html)) signals += 1;
// SQL has few symbols: lines that open with a clause keyword are its shape.
if (
share((l) =>
/^\s*(SELECT|FROM|WHERE|(LEFT |RIGHT |INNER )?JOIN|ORDER BY|GROUP BY|HAVING|INSERT|VALUES|UPDATE|SET|DELETE|LIMIT|AND|OR)\b/.test(
l,
),
) >= 0.5
) {
signals += 1;
}
if (signals < 2) return { isCode: false };
return { isCode: true, lang: guessLanguage(text) };
}