diff --git a/.changeset/max-lines.md b/.changeset/max-lines.md new file mode 100644 index 000000000..89b6cecc5 --- /dev/null +++ b/.changeset/max-lines.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-check-common': minor +--- + +Add `MaxLines` check that enforces a maximum number of lines per Liquid file, with configurable options to skip blank lines, comments, schema blocks, and doc blocks. diff --git a/packages/theme-check-common/src/checks/index.ts b/packages/theme-check-common/src/checks/index.ts index be0370c41..7bb1cae7e 100644 --- a/packages/theme-check-common/src/checks/index.ts +++ b/packages/theme-check-common/src/checks/index.ts @@ -26,6 +26,7 @@ import { JSONSyntaxError } from './json-syntax-error'; import { LiquidFreeSettings } from './liquid-free-settings'; import { LiquidHTMLSyntaxError } from './liquid-html-syntax-error'; import { MatchingTranslations } from './matching-translations'; +import { MaxLines } from './max-lines'; import { MissingAsset } from './missing-asset'; import { MissingContentForArguments } from './missing-content-for-arguments'; import { MissingRenderSnippetArguments } from './missing-render-snippet-arguments'; @@ -96,6 +97,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [ LiquidFreeSettings, LiquidHTMLSyntaxError, MatchingTranslations, + MaxLines, MissingAsset, MissingContentForArguments, MissingRenderSnippetArguments, diff --git a/packages/theme-check-common/src/checks/max-lines/index.spec.ts b/packages/theme-check-common/src/checks/max-lines/index.spec.ts new file mode 100644 index 000000000..2aa72ecf2 --- /dev/null +++ b/packages/theme-check-common/src/checks/max-lines/index.spec.ts @@ -0,0 +1,302 @@ +import { expect, describe, it } from 'vitest'; +import { runLiquidCheck, check } from '../../test'; +import { MaxLines } from './index'; + +const sectionFile = 'sections/my-section.liquid'; + +describe('Module: MaxLines', () => { + it('does not report when file is within the default limit', async () => { + const source = Array(300).fill('
').join('\n'); + const offenses = await runLiquidCheck(MaxLines, source, sectionFile); + expect(offenses).toHaveLength(0); + }); + + it('reports when file exceeds the default limit', async () => { + const source = Array(301).fill('
').join('\n'); + const offenses = await runLiquidCheck(MaxLines, source, sectionFile); + expect(offenses).toHaveLength(1); + expect(offenses.at(0)?.message).toMatch(/301.*300/); + }); + + it('reports with a custom max', async () => { + const source = Array(6).fill('
').join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 5 } }, + ); + expect(offenses).toHaveLength(1); + expect(offenses.at(0)?.message).toMatch(/6.*5/); + }); + + it('does not report when file equals the max', async () => { + const source = Array(5).fill('
').join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 5 } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('reports the offset at the first excess line', async () => { + const line1 = '
line1
'; + const line2 = '
line2
'; + const line3 = '
line3
'; + const source = [line1, line2, line3].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2 } }, + ); + expect(offenses).toHaveLength(1); + expect(offenses.at(0)?.start.index).toBe(line1.length + 1 + line2.length + 1); + }); + + describe('skipBlankLines', () => { + it('counts blank lines by default', async () => { + const source = Array(3).fill('
').join('\n\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 4 } }, + ); + expect(offenses).toHaveLength(1); + }); + + it('skips blank lines when enabled', async () => { + const source = Array(3).fill('
').join('\n\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 4, skipBlankLines: true } }, + ); + expect(offenses).toHaveLength(0); + }); + }); + + describe('skipComments', () => { + it('counts liquid comment lines by default', async () => { + const source = [ + '
', + '{% comment %}', + 'This is a comment', + '{% endcomment %}', + '
', + ].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 4 } }, + ); + expect(offenses).toHaveLength(1); + }); + + it('skips liquid comment blocks when enabled', async () => { + const source = [ + '
', + '{% comment %}', + 'This is a comment', + '{% endcomment %}', + '
', + ].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 4, skipComments: true } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('skips liquid comment blocks with dash syntax', async () => { + const source = [ + '
', + '{%- comment -%}', + 'This is a comment', + '{%- endcomment -%}', + '
', + ].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2, skipComments: true } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('skips HTML comment lines when enabled', async () => { + const source = ['
', '', '
'].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2, skipComments: true } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('skips inline liquid comment blocks', async () => { + const source = ['
', '{% comment %}inline{% endcomment %}', '
'].join( + '\n', + ); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2, skipComments: true } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('skips {% # inline %} comments', async () => { + const source = ['
', '{% # this is an inline comment %}', '
'].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2, skipComments: true } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('skips {%- # inline -%} comments with dash syntax', async () => { + const source = ['
', '{%- # this is an inline comment -%}', '
'].join( + '\n', + ); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2, skipComments: true } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('counts {% # inline %} comments when skipComments is false', async () => { + const source = ['
', '{% # this is an inline comment %}', '
'].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2 } }, + ); + expect(offenses).toHaveLength(1); + }); + }); + + describe('skipSchema', () => { + it('skips schema block by default', async () => { + const source = ['
', '{% schema %}', '{}', '{% endschema %}', '
'].join( + '\n', + ); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2 } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('counts schema lines when skipSchema is false', async () => { + const source = ['
', '{% schema %}', '{}', '{% endschema %}', '
'].join( + '\n', + ); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2, skipSchema: false } }, + ); + expect(offenses).toHaveLength(1); + }); + + it('skips schema block with dash syntax', async () => { + const source = [ + '
', + '{%- schema -%}', + '{}', + '{%- endschema -%}', + '
', + ].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2 } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('skips start and end tags as well as inner content', async () => { + const source = [ + '
', + '{% schema %}', + '{ "name": "My section",', + ' "settings": [] }', + '{% endschema %}', + '
', + ].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2 } }, + ); + expect(offenses).toHaveLength(0); + }); + }); + + describe('skipDoc', () => { + it('skips doc block by default', async () => { + const source = ['
', '{% doc %}', 'Some docs', '{% enddoc %}', '
'].join( + '\n', + ); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2 } }, + ); + expect(offenses).toHaveLength(0); + }); + + it('counts doc block lines when skipDoc is false', async () => { + const source = ['
', '{% doc %}', 'Some docs', '{% enddoc %}', '
'].join( + '\n', + ); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 4, skipDoc: false } }, + ); + expect(offenses).toHaveLength(1); + }); + + it('skips doc block with dash syntax', async () => { + const source = [ + '
', + '{%- doc -%}', + 'Some docs', + '{%- enddoc -%}', + '
', + ].join('\n'); + const offenses = await check( + { [sectionFile]: source }, + [MaxLines], + {}, + { MaxLines: { enabled: true, max: 2, skipDoc: true } }, + ); + expect(offenses).toHaveLength(0); + }); + }); +}); diff --git a/packages/theme-check-common/src/checks/max-lines/index.ts b/packages/theme-check-common/src/checks/max-lines/index.ts new file mode 100644 index 000000000..e9cbd3b77 --- /dev/null +++ b/packages/theme-check-common/src/checks/max-lines/index.ts @@ -0,0 +1,94 @@ +import { LiquidCheckDefinition, SchemaProp, Severity, SourceCodeType } from '../../types'; + +const schema = { + max: SchemaProp.number(300), + skipBlankLines: SchemaProp.boolean(false), + skipComments: SchemaProp.boolean(false), + skipSchema: SchemaProp.boolean(true), + skipDoc: SchemaProp.boolean(true), +}; + +export const MaxLines: LiquidCheckDefinition = { + meta: { + code: 'MaxLines', + name: 'Max Lines', + docs: { + description: + 'Enforce a maximum number of lines per file to keep files focused and maintainable.', + recommended: false, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema, + targets: [], + }, + + create(context) { + const skipRanges: Array<[number, number]> = []; + + return { + async LiquidRawTag(node) { + const { skipSchema, skipComments, skipDoc } = context.settings; + if ( + (skipSchema && node.name === 'schema') || + (skipComments && node.name === 'comment') || + (skipDoc && node.name === 'doc') + ) { + skipRanges.push([node.position.start, node.position.end]); + } + }, + + async LiquidTag(node) { + if (context.settings.skipComments && node.name === '#') { + skipRanges.push([node.position.start, node.position.end]); + } + }, + + async HtmlComment(node) { + if (context.settings.skipComments) { + skipRanges.push([node.position.start, node.position.end]); + } + }, + + async onCodePathEnd(file) { + const { max, skipBlankLines } = context.settings; + const lines = file.source.split('\n'); + + const lineStartOffsets: number[] = []; + let offset = 0; + for (const line of lines) { + lineStartOffsets.push(offset); + offset += line.length + 1; + } + + const countingLineIndices: number[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ''; + if (skipBlankLines && line.trim() === '') continue; + + const lineStart = lineStartOffsets[i] ?? 0; + const lineEnd = lineStart + line.length; + + const isSkipped = skipRanges.some(([s, e]) => lineEnd >= s && lineStart <= e); + + if (!isSkipped) countingLineIndices.push(i); + } + + if (countingLineIndices.length > max) { + const excessLineIndex = countingLineIndices.at(max); + const excessLine = excessLineIndex !== undefined ? lines.at(excessLineIndex) : undefined; + + if (excessLineIndex !== undefined && excessLine !== undefined) { + const startIndex = lineStartOffsets[excessLineIndex] ?? 0; + context.report({ + message: `File has too many lines (${countingLineIndices.length}). Maximum allowed is ${max}.`, + startIndex, + endIndex: startIndex + excessLine.length, + }); + } + } + }, + }; + }, +};