diff --git a/extension/src/help/documentationPresenter.ts b/extension/src/help/documentationPresenter.ts index e9ad9c22..8ac35fda 100644 --- a/extension/src/help/documentationPresenter.ts +++ b/extension/src/help/documentationPresenter.ts @@ -259,6 +259,33 @@ export namespace Annotation { lines.push(`@${MarkdownHelpers.italic('remarks')} — ${docs.remarks.value}`); } + if (docs?.examples?.length > 0) { + for (const example of docs.examples) { + lines.push(MarkdownHelpers.divider); + const header = `@${MarkdownHelpers.italic('example')}`; + const nlIdx = example.value.indexOf('\n'); + if (nlIdx < 0) { + lines.push(example.value ? `${header} — ${example.value}` : header); + } else { + const inlineDesc = example.value.substring(0, nlIdx); + const rest = example.value.substring(nlIdx + 1); + lines.push(inlineDesc ? `${header} — ${inlineDesc}` : header); + if (rest) { + lines.push(''); + if (rest.trimStart().startsWith('```')) { + // Normalize only the opening fence line to use the autolisp language ID + const eol = rest.indexOf('\n'); + const openLine = eol >= 0 ? rest.substring(0, eol) : rest; + const body = eol >= 0 ? rest.substring(eol) : ''; + lines.push(openLine.replace(/^(\s*)```\w*/, '$1```autolisp') + body); + } else { + lines.push(`\`\`\`autolisp\n${rest}\n\`\`\``); + } + } + } + } + } + lines.push(MarkdownHelpers.divider); lines.push(`${MarkdownHelpers.italic('source')} — ${path.basename(sourceFile)}`); diff --git a/extension/src/parsing/comments.ts b/extension/src/parsing/comments.ts index 55b703e2..f266100c 100644 --- a/extension/src/parsing/comments.ts +++ b/extension/src/parsing/comments.ts @@ -13,6 +13,7 @@ export interface ILispDocs { returns?: ILspDocPair; description?: ILspDocPair; remarks?: ILspDocPair; + examples?: Array; } function normalizeComment(str: string) : string { @@ -23,6 +24,12 @@ function normalizeComment(str: string) : string { .trim(); } +function normalizeExampleLine(str: string): string { + // Strip up to 4 spaces of block-comment indentation while preserving all content, + // including legitimate AutoLISP comment characters (;) inside example code. + return str.replace(/^ {1,4}/, ''); +} + /** * This function extracts the user documentation into a basic structure for completion data * @param value This should be a LispAtom that represents a comment or comment block @@ -31,20 +38,34 @@ function normalizeComment(str: string) : string { export function parseDocumentation(value: ILispFragment): ILispDocs { const result: ILispDocs = { }; let active: ILspDocPair = null; + let inExample = false; + let inCodeFence = false; if (!value.isComment()) { return result; } - + const facets = value.symbol.replace(/\r\n/g, '\n').split('\n'); for (let i = 0; i < facets.length; i++) { const line = normalizeComment(facets[i]); + + // Track fenced code blocks to avoid mis-treating @ inside code as tags + if (inExample && normalizeExampleLine(facets[i]).trimStart().startsWith('```')) { + inCodeFence = !inCodeFence; + } + if (line === '' || line.toUpperCase().startsWith('@GLOBAL')) { + // Preserve blank lines within example content (but not comment delimiters like |;) + if (inExample && active && line === '' && facets[i].trim() === '') { + active.value += '\n'; + } continue; } - if (line.startsWith('@') && line.includes(' ')) { - const first = line.substring(0, line.indexOf(' ')).toUpperCase(); - const content = line.substring(first.length).trim(); + if (!inCodeFence && line.startsWith('@') && (line.includes(' ') || line.toUpperCase().startsWith('@EXAMPLE'))) { + inExample = false; + const spaceIdx = line.indexOf(' '); + const first = spaceIdx >= 0 ? line.substring(0, spaceIdx).toUpperCase() : line.toUpperCase(); + const content = spaceIdx >= 0 ? line.substring(first.length).trim() : ''; if (first.startsWith('@REMARK')) { result['remarks'] = active = { name: 'Remarks', value: content }; } else if (first.startsWith('@DESC')) { @@ -54,8 +75,15 @@ export function parseDocumentation(value: ILispFragment): ILispDocs { } else if (first.startsWith('@PARAM')) { if (!result['params']) { result['params'] = []; - } + } result['params'].push(active = { name: 'Param', value: content }); + } else if (first.startsWith('@EXAMPLE')) { + if (!result['examples']) { + result['examples'] = []; + } + result['examples'].push(active = { name: 'Example', value: content }); + inExample = true; + inCodeFence = false; } else { // if it started with @ and its not roughly a predesignated @TYPE, then // do nothing and stop the process that assembles the previous @TYPE @@ -63,7 +91,11 @@ export function parseDocumentation(value: ILispFragment): ILispDocs { } } else { if (active) { - active.value += ' ' + line; + if (inExample) { + active.value += '\n' + normalizeExampleLine(facets[i]); + } else { + active.value += ' ' + line; + } } else if (!result['description']) { // this handles implied short descriptions as the first available content result['description'] = active = { name: 'Description', value: line }; diff --git a/extension/src/test/suite/parsing.comments.test.ts b/extension/src/test/suite/parsing.comments.test.ts index 47400898..b59435c1 100644 --- a/extension/src/test/suite/parsing.comments.test.ts +++ b/extension/src/test/suite/parsing.comments.test.ts @@ -4,6 +4,10 @@ import { Position } from 'vscode'; import { getBlockCommentParamNameRange, parseDocumentation } from '../../parsing/comments'; import { ReadonlyDocument } from '../../project/readOnlyDocument'; +function memDoc(content: string): ReadonlyDocument { + return ReadonlyDocument.createMemoryDocument(content, 'autolisp'); +} + suite("Parsing: Comments Tests", function () { @@ -82,4 +86,67 @@ suite("Parsing: Comments Tests", function () { }); + test("@Example tag with inline description and fenced code block", function () { + const src = ';|\n @Example How to use it\n ```lisp\n (f 2)\n ```\n|;'; + const doc = parseDocumentation(memDoc(src).documentContainer.atoms[0]); + expect(doc.examples).to.not.be.undefined; + expect(doc.examples.length).to.equal(1); + expect(doc.examples[0].value).to.include('How to use it'); + expect(doc.examples[0].value).to.include('```lisp'); + expect(doc.examples[0].value).to.include('(f 2)'); + }); + + test("@Example tag with no inline description", function () { + const src = ';|\n @Example\n ```lisp\n (hello)\n ```\n|;'; + const doc = parseDocumentation(memDoc(src).documentContainer.atoms[0]); + expect(doc.examples).to.not.be.undefined; + expect(doc.examples.length).to.equal(1); + expect(doc.examples[0].value).to.include('```lisp'); + expect(doc.examples[0].value).to.include('(hello)'); + }); + + test("Multiple @Example tags", function () { + const src = ';|\n @Example First example\n ```lisp\n (f 1)\n ```\n @Example Second example\n ```lisp\n (f 2)\n ```\n|;'; + const doc = parseDocumentation(memDoc(src).documentContainer.atoms[0]); + expect(doc.examples).to.not.be.undefined; + expect(doc.examples.length).to.equal(2); + expect(doc.examples[0].value).to.include('First example'); + expect(doc.examples[1].value).to.include('Second example'); + }); + + test("@Example alongside @Param, @Returns, @Remarks", function () { + const src = ';|\n A test function\n @Param x int: the input\n @Returns int\n @Remarks some notes\n @Example usage\n ```lisp\n (myfn 5)\n ```\n|;'; + const doc = parseDocumentation(memDoc(src).documentContainer.atoms[0]); + expect(doc.description.value).to.include('A test function'); + expect(doc.params.length).to.equal(1); + expect(doc.returns.value).to.include('int'); + expect(doc.remarks.value).to.include('some notes'); + expect(doc.examples.length).to.equal(1); + expect(doc.examples[0].value).to.include('usage'); + expect(doc.examples[0].value).to.include('(myfn 5)'); + }); + + test("@Example one-liner with no code block", function () { + const src = ';|\n @Example call it like `(f 2)`\n|;'; + const doc = parseDocumentation(memDoc(src).documentContainer.atoms[0]); + expect(doc.examples).to.not.be.undefined; + expect(doc.examples.length).to.equal(1); + expect(doc.examples[0].value).to.equal('call it like `(f 2)`'); + }); + + test("@Example preserves leading semicolons in example code", function () { + const src = ';|\n @Example usage\n ; this is a lisp comment\n (f 2) ; inline comment\n|;'; + const doc = parseDocumentation(memDoc(src).documentContainer.atoms[0]); + expect(doc.examples[0].value).to.include('; this is a lisp comment'); + expect(doc.examples[0].value).to.include('(f 2) ; inline comment'); + }); + + test("@Example with @ inside code fence is not treated as a tag", function () { + const src = ';|\n @Example code with at-sign\n ```lisp\n @somevar\n ```\n|;'; + const doc = parseDocumentation(memDoc(src).documentContainer.atoms[0]); + expect(doc.examples.length).to.equal(1); + expect(doc.examples[0].value).to.include('@somevar'); + }); + + }); \ No newline at end of file diff --git a/extension/src/test/suite/providers.hoverProvider.test.ts b/extension/src/test/suite/providers.hoverProvider.test.ts index 861b2402..00a9470e 100644 --- a/extension/src/test/suite/providers.hoverProvider.test.ts +++ b/extension/src/test/suite/providers.hoverProvider.test.ts @@ -240,6 +240,46 @@ suite("Providers: Hover", function () { }); + test("UserDefined LSP @Example one-liner - Markdown Verification", function () { + try { + const src = ';|\n @Returns int\n @Example call it like `(double 5)`\n|;\n(defun double (x) (* 2 x))'; + const exDoc = ReadonlyDocument.createMemoryDocument(src, DocumentServices.Selectors.LSP); + const docs = parseDocumentation(exDoc.documentContainer.atoms[0]); + const flatView = exDoc.documentContainer.flatten(); + const defunAtom = flatView.find(a => a.symbol === 'double'); + const md = Annotation.asMarkdown(defunAtom, -1, [], docs, 'test.lsp'); + expect(md.value).to.include('@*example*'); + expect(md.value).to.include('call it like `(double 5)`'); + // one-liner: no code block should be injected + expect(md.value).to.not.include('```autolisp'); + } + catch (err) { + assert.fail(`@Example one-liner hover rendering failed: ${err}`); + } + }); + + + test("UserDefined LSP @Example - Markdown Verification", function () { + try { + const src = ';|\n A documented function\n @Param x int: the input\n @Returns int\n @Example Double the input\n ```lisp\n (double 5)\n ```\n|;\n(defun double (x) (* 2 x))'; + const exDoc = ReadonlyDocument.createMemoryDocument(src, DocumentServices.Selectors.LSP); + const commentAtom = exDoc.documentContainer.atoms[0]; + const docs = parseDocumentation(commentAtom); + const flatView = exDoc.documentContainer.flatten(); + const defunAtom = flatView.find(a => a.symbol === 'double'); + expect(defunAtom).to.not.be.undefined; + const md = Annotation.asMarkdown(defunAtom, -1, [], docs, 'test.lsp'); + expect(md.value).to.include('@*example*'); + expect(md.value).to.include('Double the input'); + expect(md.value).to.include('```autolisp'); + expect(md.value).to.include('(double 5)'); + } + catch (err) { + assert.fail(`@Example hover rendering failed: ${err}`); + } + }); + + test("UserDefined LSP ActiveIndex - Markdown Verification", function () { let passedCount = 0; try { diff --git a/extension/syntaxes/autolisp.tmLanguage.json b/extension/syntaxes/autolisp.tmLanguage.json index e350c4e1..0d7d5c9e 100644 --- a/extension/syntaxes/autolisp.tmLanguage.json +++ b/extension/syntaxes/autolisp.tmLanguage.json @@ -53,7 +53,7 @@ "patterns": [ { "name": "string.regexp", - "begin": "^\\s*(?i)(@PARAM|@REMARKS|@DESCRIPTION|@RETURNS)(?=\\s.+)", + "begin": "^\\s*(?i)(@PARAM|@REMARKS|@DESCRIPTION|@RETURNS|@EXAMPLES?)(?=\\s|$)", "end": "(\\n|\\s)" }, {