|
1 | | -import { createRule, paramMessage, getDoc } from '@typespec/compiler' |
| 1 | +import { |
| 2 | + createRule, |
| 3 | + defineCodeFix, |
| 4 | + getDoc, |
| 5 | + getSourceLocation, |
| 6 | + paramMessage, |
| 7 | +} from '@typespec/compiler' |
| 8 | +import * as prettier from 'prettier' |
| 9 | +import { |
| 10 | + detectNewline, |
| 11 | + extractMarkdownFromDocComment, |
| 12 | + getIndentBefore, |
| 13 | + wrapMarkdownAsDocComment, |
| 14 | +} from './utils.js' |
2 | 15 |
|
3 | 16 | export const docDecoratorRule = createRule({ |
4 | 17 | name: 'doc-decorator', |
@@ -60,3 +73,133 @@ export const docDecoratorRule = createRule({ |
60 | 73 | }, |
61 | 74 | }), |
62 | 75 | }) |
| 76 | + |
| 77 | +/** |
| 78 | + * Format a doc-comment Markdown body through Prettier. |
| 79 | + * Returns the formatted Markdown body (no `/** *\/` framing). |
| 80 | + * @param {string} markdown |
| 81 | + * @param {{ printWidth?: number, proseWrap?: 'always' | 'never' | 'preserve' }} [options] |
| 82 | + */ |
| 83 | +async function formatDocMarkdown(markdown, options = {}) { |
| 84 | + if (markdown.trim() === '') return '' |
| 85 | + return await prettier.format(markdown, { |
| 86 | + parser: 'markdown', |
| 87 | + printWidth: options.printWidth ?? 80, |
| 88 | + proseWrap: options.proseWrap ?? 'always', |
| 89 | + }) |
| 90 | +} |
| 91 | + |
| 92 | +/** |
| 93 | + * Build a code fix that replaces a doc comment with a precomputed string. |
| 94 | + * The Prettier work happens before this is constructed; the fix callback is |
| 95 | + * sync and just emits the replacement. |
| 96 | + * |
| 97 | + * @param {import('@typespec/compiler').SourceLocation} location |
| 98 | + * The full `/** ... *\/` source range. |
| 99 | + * @param {string} newText The replacement text, including `/**` and `*\/`. |
| 100 | + */ |
| 101 | +function createFormatDocCommentCodeFix(location, newText) { |
| 102 | + return defineCodeFix({ |
| 103 | + id: 'format-doc-comment', |
| 104 | + label: 'Format doc comment', |
| 105 | + fix(context) { |
| 106 | + return context.replaceText(location, newText) |
| 107 | + }, |
| 108 | + }) |
| 109 | +} |
| 110 | + |
| 111 | +/** |
| 112 | + * Collect every `DocNode` reachable from the program by walking semantic |
| 113 | + * targets that can carry doc comments. We use the existing semantic listener |
| 114 | + * surface (model/property/enum/etc.) rather than a private AST walker. |
| 115 | + */ |
| 116 | +function collectDocNodes(target, sink) { |
| 117 | + const node = target.node |
| 118 | + if (!node || !node.docs || node.docs.length === 0) return |
| 119 | + for (const doc of node.docs) sink.push(doc) |
| 120 | +} |
| 121 | + |
| 122 | +export const docFormatRule = createRule({ |
| 123 | + name: 'doc-format', |
| 124 | + severity: 'warning', |
| 125 | + description: |
| 126 | + 'Format doc comment bodies as Markdown using Prettier (proseWrap=always).', |
| 127 | + messages: { |
| 128 | + default: |
| 129 | + 'Doc comment is not formatted. Apply the suggested fix to reformat as Markdown.', |
| 130 | + }, |
| 131 | + // Async because Prettier 3.x's `format` is async. |
| 132 | + async: true, |
| 133 | + create: (context) => { |
| 134 | + /** @type {import('@typespec/compiler').DocNode[]} */ |
| 135 | + const docNodes = [] |
| 136 | + |
| 137 | + const collect = (target) => collectDocNodes(target, docNodes) |
| 138 | + |
| 139 | + return { |
| 140 | + model: collect, |
| 141 | + modelProperty: collect, |
| 142 | + enum: collect, |
| 143 | + enumMember: collect, |
| 144 | + union: collect, |
| 145 | + unionVariant: collect, |
| 146 | + operation: collect, |
| 147 | + interface: collect, |
| 148 | + scalar: collect, |
| 149 | + namespace: collect, |
| 150 | + |
| 151 | + async exit() { |
| 152 | + // Deduplicate: a doc may be visited via multiple semantic kinds. |
| 153 | + const seen = new Set() |
| 154 | + const work = [] |
| 155 | + for (const doc of docNodes) { |
| 156 | + if (seen.has(doc)) continue |
| 157 | + seen.add(doc) |
| 158 | + work.push(processDoc(doc, context)) |
| 159 | + } |
| 160 | + await Promise.all(work) |
| 161 | + }, |
| 162 | + } |
| 163 | + }, |
| 164 | +}) |
| 165 | + |
| 166 | +/** |
| 167 | + * Compute a formatted replacement for a single DocNode and, if it differs |
| 168 | + * from the source, report a diagnostic with an attached code fix. |
| 169 | + * @param {import('@typespec/compiler').DocNode} doc |
| 170 | + * @param {import('@typespec/compiler').LinterRuleContext<any>} context |
| 171 | + */ |
| 172 | +async function processDoc(doc, context) { |
| 173 | + const location = getSourceLocation(doc) |
| 174 | + const source = location.file.text |
| 175 | + const raw = source.slice(location.pos, location.end) |
| 176 | + |
| 177 | + // Defensive: only format actual `/** ... */` blocks. |
| 178 | + if (!raw.startsWith('/**') || !raw.endsWith('*/')) return |
| 179 | + |
| 180 | + const indent = getIndentBefore(source, location.pos) |
| 181 | + const newline = detectNewline(source) |
| 182 | + |
| 183 | + let markdown |
| 184 | + try { |
| 185 | + markdown = extractMarkdownFromDocComment(raw) |
| 186 | + } catch { |
| 187 | + return |
| 188 | + } |
| 189 | + |
| 190 | + let formatted |
| 191 | + try { |
| 192 | + formatted = await formatDocMarkdown(markdown) |
| 193 | + } catch { |
| 194 | + // If Prettier can't parse the body, leave it alone. |
| 195 | + return |
| 196 | + } |
| 197 | + |
| 198 | + const replacement = wrapMarkdownAsDocComment(formatted, indent, newline) |
| 199 | + if (replacement === raw) return |
| 200 | + |
| 201 | + context.reportDiagnostic({ |
| 202 | + target: doc, |
| 203 | + codefixes: [createFormatDocCommentCodeFix(location, replacement)], |
| 204 | + }) |
| 205 | +} |
0 commit comments