1+ /*
2+ * Licensed to Elasticsearch B.V. under one or more contributor
3+ * license agreements. See the NOTICE file distributed with
4+ * this work for additional information regarding copyright
5+ * ownership. Elasticsearch B.V. licenses this file to you under
6+ * the Apache License, Version 2.0 (the "License"); you may
7+ * not use this file except in compliance with the License.
8+ * You may obtain a copy of the License at
9+ *
10+ * http://www.apache.org/licenses/LICENSE-2.0
11+ *
12+ * Unless required by applicable law or agreed to in writing,
13+ * software distributed under the License is distributed on an
14+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+ * KIND, either express or implied. See the License for the
16+ * specific language governing permissions and limitations
17+ * under the License.
18+ */
19+ import { ESLintUtils } from '@typescript-eslint/utils'
20+ import { lint as markdownlintSync } from 'markdownlint/sync'
21+
22+ const isLineEmpty = line => ! line || line . trim ( ) === ''
23+
24+ const parseJSDoc = ( jsdoc ) => {
25+ const lines = jsdoc . value
26+ . split ( '\n' )
27+ . map ( line => line . trim ( ) . replace ( / ^ \* \s ? / , '' ) )
28+
29+ return {
30+ lines,
31+ summaryIndex : lines . findIndex ( line => ! isLineEmpty ( line ) ) ,
32+ lastNonEmptyIndex : lines . findLastIndex ( line => ! isLineEmpty ( line ) )
33+ }
34+ }
35+
36+ const reconstructJSDoc = ( lines ) => {
37+ let trimmedLines = [ ...lines ]
38+
39+ while ( trimmedLines . length > 0 && isLineEmpty ( trimmedLines [ trimmedLines . length - 1 ] ) ) {
40+ trimmedLines . pop ( )
41+ }
42+
43+ if ( trimmedLines . length > 0 && isLineEmpty ( trimmedLines [ 0 ] ) ) {
44+ trimmedLines = trimmedLines . slice ( 1 )
45+ }
46+
47+ const lineContent = trimmedLines . map ( line => line ? ` * ${ line } ` : ' *' ) . join ( '\n' )
48+ return `/**\n${ lineContent } \n */`
49+ }
50+
51+ const fixers = {
52+ firstLineShouldBeEmpty : ( lines ) => {
53+ if ( ! isLineEmpty ( lines [ 0 ] ) ) {
54+ return [ '' , lines [ 0 ] , ...lines . slice ( 1 ) ]
55+ }
56+ return lines
57+ } ,
58+
59+ summaryMissingPeriod : ( lines , { summaryIndex } ) => {
60+ if ( summaryIndex !== - 1 ) {
61+ const fixed = [ ...lines ]
62+ fixed [ summaryIndex ] = lines [ summaryIndex ] . trimEnd ( ) + '.'
63+ return fixed
64+ }
65+ return lines
66+ } ,
67+
68+ lineAfterSummaryShouldBeEmpty : ( lines , { summaryIndex } ) => {
69+ if ( summaryIndex !== - 1 ) {
70+ const fixed = [ ...lines ]
71+ fixed . splice ( summaryIndex + 1 , 0 , '' )
72+ return fixed
73+ }
74+ return lines
75+ }
76+ }
77+
78+ const createJSDocFixer = ( jsdoc , messageId ) => {
79+ const parsed = parseJSDoc ( jsdoc )
80+ const fixer = fixers [ messageId ]
81+
82+ if ( ! fixer ) return null
83+
84+ const fixedLines = fixer ( parsed . lines , parsed )
85+ return ( fix ) => fix . replaceTextRange ( [ jsdoc . range [ 0 ] , jsdoc . range [ 1 ] ] , reconstructJSDoc ( fixedLines ) )
86+ }
87+
88+ const validateMarkdown = ( lines , summaryIndex , startLine , column , markdownlintConfig ) => {
89+ const errors = [ ]
90+
91+ // Extract description content
92+ const descriptionStartIndex = summaryIndex + 2
93+ if ( descriptionStartIndex >= lines . length ) {
94+ return errors
95+ }
96+
97+ const descriptionLines = lines . slice ( descriptionStartIndex )
98+ const description = descriptionLines . join ( '\n' ) . trim ( )
99+
100+ if ( ! description ) {
101+ return errors
102+ }
103+
104+ const result = markdownlintSync ( {
105+ strings : {
106+ 'description' : description
107+ } ,
108+ config : markdownlintConfig
109+ } )
110+
111+ // Convert markdownlint errors to ESLint errors
112+ const markdownErrors = result . description || [ ]
113+ markdownErrors . forEach ( error => {
114+ const lineOffset = descriptionStartIndex + error . lineNumber - 1
115+ errors . push ( {
116+ messageId : 'markdownLintError' ,
117+ line : startLine + lineOffset ,
118+ column,
119+ canFix : false ,
120+ data : {
121+ rule : error . ruleNames . join ( '/' ) ,
122+ detail : error . ruleDescription + ( error . errorDetail ? `: ${ error . errorDetail } ` : '' )
123+ }
124+ } )
125+ } )
126+
127+ return errors
128+ }
129+
130+ const validateJSDoc = ( jsdoc , markdownlintConfig ) => {
131+ const { lines, summaryIndex, lastNonEmptyIndex } = parseJSDoc ( jsdoc )
132+ const { line : startLine , column } = jsdoc . loc . start
133+
134+ const createError = ( messageId , lineOffset , data , canFix = false ) => ( {
135+ messageId,
136+ line : startLine + lineOffset ,
137+ column,
138+ canFix,
139+ ...( data && { data } )
140+ } )
141+
142+ const errors = [ ]
143+
144+ if ( ! isLineEmpty ( lines [ 0 ] ) ) {
145+ errors . push ( createError ( 'firstLineShouldBeEmpty' , 0 , null , true ) )
146+ }
147+
148+ if ( summaryIndex === - 1 || ( isLineEmpty ( lines [ 0 ] ) && summaryIndex !== 1 ) ) {
149+ errors . push ( createError ( 'missingSummary' , 1 ) )
150+ return errors
151+ }
152+
153+ const summary = lines [ summaryIndex ]
154+
155+ if ( / [ * ` \[ \] # ] / . test ( summary ) ) {
156+ errors . push ( createError ( 'summaryHasMarkup' , summaryIndex ) )
157+ }
158+
159+ if ( ! summary . trim ( ) . endsWith ( '.' ) ) {
160+ errors . push ( createError ( 'summaryMissingPeriod' , summaryIndex , null , true ) )
161+ }
162+
163+ const lineAfterSummary = summaryIndex + 1
164+ if ( lineAfterSummary < lines . length && ! isLineEmpty ( lines [ lineAfterSummary ] ) ) {
165+ errors . push ( createError ( 'lineAfterSummaryShouldBeEmpty' , lineAfterSummary , null , true ) )
166+ }
167+
168+ // Validate markdown in description
169+ errors . push ( ...validateMarkdown ( lines , summaryIndex , startLine , column , markdownlintConfig ) )
170+
171+ return errors
172+ }
173+
174+ export const jsdocEndpointCheck = ESLintUtils . RuleCreator . withoutDocs (
175+ {
176+ name : 'jsdoc-endpoint-check' ,
177+ meta : {
178+ type : 'layout' ,
179+ docs : {
180+ description : 'Checks that the JSDoc for an endpoint has the correct format' ,
181+ recommended : 'error'
182+ } ,
183+ fixable : 'code' ,
184+ hasSuggestions : true ,
185+ messages : {
186+ firstLineShouldBeEmpty : 'JSDoc first line should be empty' ,
187+ summaryHasMarkup : 'JSDoc summary should not contain markup' ,
188+ summaryMissingPeriod : 'JSDoc summary should end with a period' ,
189+ lineAfterSummaryShouldBeEmpty : 'Line after summary should be empty' ,
190+ endpointJSDocMissing : 'The JSDoc for an endpoint is missing.' ,
191+ markdownLintError : 'Markdown error ({{rule}}): {{detail}}'
192+ } ,
193+ schema : [
194+ {
195+ type : 'object' ,
196+ properties : {
197+ markdownlint : {
198+ type : 'object' ,
199+ description : 'Configuration object for markdownlint rules' ,
200+ additionalProperties : true
201+ }
202+ } ,
203+ additionalProperties : false
204+ }
205+ ]
206+ } ,
207+ defaultOptions : [
208+ {
209+ markdownlint : {
210+ default : true ,
211+ // Disable rules that don't make sense for JSDoc descriptions
212+ 'MD041' : false ,
213+ 'MD013' : false ,
214+ 'MD033' : false ,
215+ 'MD034' : false ,
216+ 'MD047' : false
217+ }
218+ }
219+ ] ,
220+ create ( context ) {
221+ const sourceCode = context . sourceCode || context . getSourceCode ( )
222+ const options = context . options [ 0 ] || { }
223+ const markdownlintConfig = options . markdownlint || context . options [ 0 ] ?. markdownlint || {
224+ default : true ,
225+ 'MD041' : false ,
226+ 'MD013' : false ,
227+ 'MD033' : false ,
228+ 'MD034' : false ,
229+ 'MD047' : false
230+ }
231+
232+ return {
233+ 'TSInterfaceDeclaration, ClassDeclaration' ( node ) {
234+ if ( node . id . name !== 'Request' ) return
235+
236+ const nodeToGetCommentsFrom =
237+ node . parent ?. type === 'ExportNamedDeclaration' ? node . parent : node
238+
239+ const comments = sourceCode . getCommentsBefore ( nodeToGetCommentsFrom )
240+ const jsdoc = comments
241+ ?. filter ( comment => comment . type === 'Block' && comment . value . startsWith ( '*' ) )
242+ . pop ( )
243+
244+ if ( ! jsdoc ) {
245+ context . report ( { node, messageId : 'endpointJSDocMissing' } )
246+ return
247+ }
248+
249+ const validationErrors = validateJSDoc ( jsdoc , markdownlintConfig )
250+ validationErrors . forEach ( ( { messageId, data, line, column, canFix } ) => {
251+ context . report ( {
252+ node,
253+ messageId,
254+ ...( data && { data } ) ,
255+ loc : { start : { line, column } } ,
256+ ...( canFix && { fix : createJSDocFixer ( jsdoc , messageId ) } ) ,
257+ ...( canFix && { suggestions : [ createJSDocFixer ( jsdoc , messageId ) ] } )
258+ } )
259+ } )
260+ }
261+ }
262+ }
263+ } )
264+
265+ export default jsdocEndpointCheck
0 commit comments