Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
});
},
Expand All @@ -254,18 +248,18 @@ 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 {
loader: 'css',
errors: [
{
text: error.message,
location,
},
],
warnings,
Expand All @@ -277,6 +271,16 @@ async function compileString(
}
}

function spanToLocation(span: SourceSpan): Partial<Location> {
return {
file: span.url && fileURLToPath(span.url),
Comment thread
alan-agius4 marked this conversation as resolved.
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<CompileResult['sourceMap'], undefined>): string {
const urlSourceMap = Buffer.from(JSON.stringify(sourceMap), 'utf-8').toString('base64');

Expand All @@ -287,24 +291,12 @@ function* extractFilesFromStack(stack: string): Iterable<string> {
const lines = stack.split('\n');
const cwd = process.cwd();

// Stack line has format of "<file> <location> <identifier>"
// Stack line has format of `<file> <line>:<column> <identifier>`
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]);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
});
});
Loading