diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts index 432ae7ba79b8..5c09413bbb9b 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { OnLoadResult, PartialMessage, PartialNote, ResolveResult } from 'esbuild'; +import type { Location, OnLoadResult, PartialMessage, PartialNote, ResolveResult } from 'esbuild'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import type { CompileResult, Exception, Syntax } from 'sass-embedded'; +import type { CompileResult, Exception, SourceSpan, Syntax } from 'sass-embedded'; import { MemoryCache } from '../../../utils/cache'; import type { SassCompiler } from '../../sass/sass-service'; import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; @@ -233,13 +233,7 @@ async function compileString( warnings.push({ text: deprecation ? 'Deprecation' : text, - location: span && { - file: span.url && fileURLToPath(span.url), - lineText: span.context, - // Sass line numbers are 0-based while esbuild's are 1-based - line: span.start.line + 1, - column: span.start.column, - }, + location: span && spanToLocation(span), notes, }); }, @@ -254,11 +248,10 @@ async function compileString( }; } catch (error) { if (isSassException(error)) { - const fileWithError = error.span.url ? fileURLToPath(error.span.url) : undefined; - + const location = spanToLocation(error.span); const watchFiles = [filePath, ...extractFilesFromStack(error.sassStack)]; - if (fileWithError) { - watchFiles.push(fileWithError); + if (location.file) { + watchFiles.push(location.file); } return { @@ -266,6 +259,7 @@ async function compileString( errors: [ { text: error.message, + location, }, ], warnings, @@ -277,6 +271,16 @@ async function compileString( } } +function spanToLocation(span: SourceSpan): Partial { + return { + file: span.url && fileURLToPath(span.url), + lineText: span.context, + // Sass line numbers are 0-based while esbuild's are 1-based + line: span.start.line + 1, + column: span.start.column, + }; +} + function sourceMapToUrlComment(sourceMap: Exclude): string { const urlSourceMap = Buffer.from(JSON.stringify(sourceMap), 'utf-8').toString('base64'); @@ -287,24 +291,12 @@ function* extractFilesFromStack(stack: string): Iterable { const lines = stack.split('\n'); const cwd = process.cwd(); - // Stack line has format of " " + // Stack line has format of ` : ` for (const line of lines) { - const segments = line.split(' '); - if (segments.length < 3) { - break; - } - - // Extract path from stack line. - // Paths may contain spaces. All segments before location are part of the file path. - let path = ''; - let index = 0; - while (!segments[index].match(/\d+:\d+/)) { - path += segments[index++]; - } - - if (path) { + const match = line.trim().match(/^(.+?)\s+\d+:\d+/); + if (match) { // Stack paths from sass are relative to the current working directory (not input file or workspace root) - yield join(cwd, path); + yield join(cwd, match[1]); } } } diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts index 216a2d18fc47..ec424208eadc 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts @@ -351,4 +351,75 @@ describe('sass-language', () => { } }); }); + + describe('error handling', () => { + const buildStub = { + initialOptions: { absWorkingDir: '/app' }, + resolve: async () => ({ path: undefined, errors: [], warnings: [] }), + } as unknown as PluginBuild; + + afterAll(() => { + shutdownSassWorkerPool(); + }); + + it('should populate structured location on Sass compilation errors', async () => { + const result = await SassStylesheetLanguage.process?.( + 'body {\n color: $undefined-var;\n}', + '/app/src/styles.scss', + 'scss', + { sourcemap: false }, + buildStub, + ); + + expect(result?.errors?.length).toBe(1); + expect(result?.errors?.[0].text).toContain('Undefined variable.'); + expect(result?.errors?.[0].location).toEqual({ + file: '/app/src/styles.scss', + line: 2, + column: 9, + lineText: jasmine.stringContaining('color: $undefined-var;'), + }); + expect(result?.watchFiles).toContain('/app/src/styles.scss'); + }); + + it('should skip stack trace lines without line:col and extract valid stack files', async () => { + spyOn(SassCompiler.prototype, 'compileStringAsync').and.rejectWith({ + message: 'Custom Sass error', + sassMessage: 'Custom Sass error', + sassStack: 'malformed stack line without location\nsrc/partials/_vars.scss 3:5 @use\n', + span: { + url: pathToFileURL('/app/src/partials/_vars.scss'), + context: '$bad: ;', + start: { line: 2, column: 4, offset: 10 }, + end: { line: 2, column: 5, offset: 11 }, + text: ';', + }, + }); + + const result = await SassStylesheetLanguage.process?.( + '@use "partials/vars";', + '/app/src/styles.scss', + 'scss', + { sourcemap: false }, + buildStub, + ); + + expect(result?.errors).toEqual([ + { + text: 'Custom Sass error', + location: { + file: '/app/src/partials/_vars.scss', + lineText: '$bad: ;', + line: 3, + column: 4, + }, + }, + ]); + expect(result?.watchFiles).toEqual([ + '/app/src/styles.scss', + join(process.cwd(), 'src/partials/_vars.scss'), + '/app/src/partials/_vars.scss', + ]); + }); + }); });