Skip to content

Fix some issues with preprocess source maps #5754

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Dec 16, 2020
Merged
Show file tree
Hide file tree
Changes from 8 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
33 changes: 22 additions & 11 deletions src/compiler/preprocess/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,18 @@ function parse_attributes(str: string) {
return attrs;
}

function get_file_basename(filename: string) {
return filename.split(/[/\\]/).pop();
}

interface Replacement {
offset: number;
length: number;
replacement: StringWithSourcemap;
}

async function replace_async(
filename: string,
file_basename: string,
source: string,
get_location: ReturnType<typeof getLocator>,
re: RegExp,
Expand All @@ -73,13 +77,13 @@ async function replace_async(
)) {
// content = unchanged source characters before the replaced segment
const content = StringWithSourcemap.from_source(
filename, source.slice(last_end, offset), get_location(last_end));
file_basename, source.slice(last_end, offset), get_location(last_end));
out.concat(content).concat(replacement);
last_end = offset + length;
}
// final_content = unchanged source characters after last replaced segment
const final_content = StringWithSourcemap.from_source(
filename, source.slice(last_end), get_location(last_end));
file_basename, source.slice(last_end), get_location(last_end));
return out.concat(final_content);
}

Expand Down Expand Up @@ -160,7 +164,7 @@ function decoded_sourcemap_from_generator(generator: any) {
* Convert a preprocessor output and its leading prefix and trailing suffix into StringWithSourceMap
*/
function get_replacement(
filename: string,
file_basename: string,
offset: number,
get_location: ReturnType<typeof getLocator>,
original: string,
Expand All @@ -171,9 +175,9 @@ function get_replacement(

// Convert the unchanged prefix and suffix to StringWithSourcemap
const prefix_with_map = StringWithSourcemap.from_source(
filename, prefix, get_location(offset));
file_basename, prefix, get_location(offset));
const suffix_with_map = StringWithSourcemap.from_source(
filename, suffix, get_location(offset + prefix.length + original.length));
file_basename, suffix, get_location(offset + prefix.length + original.length));

// Convert the preprocessed code and its sourcemap to a StringWithSourcemap
let decoded_map: DecodedSourceMap;
Expand All @@ -186,7 +190,11 @@ function get_replacement(
// import decoded sourcemap from mozilla/source-map/SourceMapGenerator
decoded_map = decoded_sourcemap_from_generator(decoded_map);
}
sourcemap_add_offset(decoded_map, get_location(offset + prefix.length));
// offset only segments pointing at original component source
const source_index = decoded_map.sources.indexOf(file_basename);
if (source_index !== -1) {
sourcemap_add_offset(decoded_map, get_location(offset + prefix.length), source_index);
}
}
const processed_with_map = StringWithSourcemap.from_processed(processed.code, decoded_map);

Expand All @@ -203,6 +211,9 @@ export default async function preprocess(
const filename = (options && options.filename) || preprocessor.filename; // legacy
const dependencies = [];

// preprocess source must be relative to itself or equal null
const file_basename = filename == null ? null : get_file_basename(filename);

const preprocessors = preprocessor
? Array.isArray(preprocessor) ? preprocessor : [preprocessor]
: [];
Expand Down Expand Up @@ -246,13 +257,13 @@ export default async function preprocess(
: /<!--[^]*?-->|<script(\s[^]*?)?(?:>([^]*?)<\/script>|\/>)/gi;

const res = await replace_async(
filename,
file_basename,
source,
get_location,
tag_regex,
async (match, attributes = '', content = '', offset) => {
const no_change = () => StringWithSourcemap.from_source(
filename, match, get_location(offset));
file_basename, match, get_location(offset));
if (!attributes && !content) {
return no_change();
}
Expand All @@ -268,7 +279,7 @@ export default async function preprocess(

if (!processed) return no_change();
if (processed.dependencies) dependencies.push(...processed.dependencies);
return get_replacement(filename, offset, get_location, content, processed, `<${tag_name}${attributes}>`, `</${tag_name}>`);
return get_replacement(file_basename, offset, get_location, content, processed, `<${tag_name}${attributes}>`, `</${tag_name}>`);
}
);
source = res.string;
Expand All @@ -285,7 +296,7 @@ export default async function preprocess(

// Combine all the source maps for each preprocessor function into one
const map: RawSourceMap = combine_sourcemaps(
filename,
file_basename,
sourcemap_list
);

Expand Down
45 changes: 29 additions & 16 deletions src/compiler/utils/string_with_sourcemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,22 @@ function last_line_length(s: string) {

// mutate map in-place
export function sourcemap_add_offset(
map: DecodedSourceMap, offset: SourceLocation
map: DecodedSourceMap, offset: SourceLocation, source_index: number
) {
if (map.mappings.length == 0) return map;
// shift columns in first line
const segment_list = map.mappings[0];
for (let segment = 0; segment < segment_list.length; segment++) {
const seg = segment_list[segment];
if (seg[3]) seg[3] += offset.column;
}
// shift lines
if (map.mappings.length == 0) return;
for (let line = 0; line < map.mappings.length; line++) {
const segment_list = map.mappings[line];
for (let segment = 0; segment < segment_list.length; segment++) {
const seg = segment_list[segment];
if (seg[2]) seg[2] += offset.line;
// shift only segments that belong to component source file
if (seg[1] === source_index) { // also ensures that seg.length >= 4
// shift column if it points at the first line
if (seg[2] === 0) {
seg[3] += offset.column;
}
// shift line
seg[2] += offset.line;
}
Comment on lines -30 to +31
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be nice to have a test for "only shift lines of one source file"
probably you have good reasons for this change, but its not yet transparent

if your solution is right, then we get another problem:
what about basename collisions?
a/index.svelte + b/index.svelte --> c/index.svelte

the sourcemap.names array is not sorted, so its not predictable
so there is no (simple) way to choose the right index.svelte file
lazy solution: print a warning.
"dear user, sorry, but our compiler knows files only by their basename .."

a more radical solution: instead of file names, use file paths.
these are always relative to the project root
all our plugins would have to preserve these paths

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test for "only shift lines of one source file"

sourcemap-offsets should cover this. Or am i misunderstanding you?

what about basename collisions?

I think that I have a different vision of the build pipeline:

  1. Preprocess
  • We assume that preprocess src === dst and map file is placed in same dir. We use basename for component in sources since it should be relative to the sourcemap file.
  • We use basename for chunks that we don't pass to preprocessors (StringWithSourcemap.from_source)
  • We pass full filename to preprocessors and expect sources to contain basename when referring component being preprocessed and relative to filename paths for external inclusions. For example, expect ["index.svelte", "../b/index.svelte"] when transforming a/index.svelte (no collision here).
  1. Compile
  • We pass filename, outputFilename and cssOutputFilename. Based on these, svelte compiler creates source map with sources as relative paths.
  • Then we combine js and css maps from compiler with preprocessed map so that segments point at locations in original source code and sources in resulted maps are relative from output map file path to original source files.
  1. Bundle (optional)
  • Should work as usual since we input compiled files with maps pointing directly at original sources.

Is there anything missing, you disagree with or that I should describe more clearly? I'd prefer not to land something cryptic or/and broken :)

P.S. Just out of interest, is it synthetic or do you know setups when preprocess step combines multiple components into one?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sourcemap-offsets should cover this.

yes! sorry. ("dear mila, please just read the source")

We use basename for component in sources since it should be relative to the sourcemap file.

yes .. i wrongly assumed that the basename-function is applied to the preprocessor-result
while its only applied to the svelte.preprocess input filename

We pass full filename to preprocessors and expect sources to contain basename when referring component being preprocessed

this is my problem with this solution ..
why do we always convert file paths to basenames?
(always, without giving an option to the user)

short answer: for consistency with the existing svelte.compile

good news: i am happy with your patch : )

long answer:

[email protected] + [email protected]
svelte.preprocess and svelte.compile receive relative paths
options = { filename: 'src/App.svelte' }

[email protected] + [email protected]
svelte.preprocess and svelte.compile receive absolute paths
options = { filename: '/home/user/project/src/App.svelte' }

why do we always convert file paths to basenames?

the actual reason here is MagicString, who turns file paths into basenames:

// svelte/src/compiler/compile/render_dom/index.ts
const css = component.stylesheet.render(
  options.filename, // file path
  !options.customElement
);

// svelte/src/compiler/compile/css/Stylesheet.ts
// Stylesheet.render
return {
  code: code.toString(),
  map: code.generateMap({
    includeContent: true,
    source: this.filename, // file path
    file
  })
};
// --> result.map.sources: basename

// magic-string/src/MagicString.js
// MagicString.generateDecodedMap
return {

  // path to basename
  file: options.file ? options.file.split(/[/\\]/).pop() : null,

  sources: [options.source ? getRelativePath(options.file || '', options.source) : null],
  sourcesContent: options.includeContent ? [this.original] : [null],
  names,
  mappings: mappings.raw
};

so the path-to-basename conversion is done for consistency with other sourcemaps, generated by svelte.compile

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we always convert file paths to basenames?

basename is just a result of computing relative path from file to itself since I assumed that we "put" results of preprocess into the same directory where source code lives.

If someone needs more control we could add additional option outputFilename to preprocess so that specific relative paths are generated instead. For example, take ./src/index.svelte, preprocess it and put into ./preprocessed/index.svelte, and then pass those files to svelte.compile independently. In this case, instead index.svelte (basename) we would have our component referred by index.svelte.map as ../src/index.svelte (relative to map file location - from {cwd}/preprocessed/index.svelte.map to {cwd}/src/index.svelte).

But I'm not sure that such feature is really needed and worth complication both in usage and implementation since usually preprocess is done in-memory and can be treated as atomic operation with compile. I might be wrong here though. But then it can be implemented separately.

So I would say it's more about simplicity, not consistency.

Copy link
Contributor

@milahu milahu Dec 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add a test for the basename conversion? something like ....

import { magic_string_bundle } from '../../helpers';

const external_filename = 'external_code.css';
export const external_code = `/* external code start */

span {
	--external_code-var: 1px;
}

/* external code end */`;

const component_filepath = 'src/input.svelte';
const component_basename = 'input.svelte';

export default {
	js_map_sources: [component_basename],
	css_map_sources: [component_basename, external_filename],
	preprocess: [
		{
			style: ({ content, filename }) => {
				return magic_string_bundle([
					{ code: external_code, filename: external_filename },
					{ code: content, filename }
				]);
			}
		}
	],
	options: {
		filename: component_filepath
	}
};

it can be implemented separately.

yes .. low priority stuff

what i meant was:
optionally, svelte should "simply" preserve all file paths (relative or absolute)
then its the bundler's job (rollup, webpack) to feed pretty file paths to svelte

for example

src/App.svelte should be preserved

when src/App.svelte includes ./style/included.css
then its resolved to src/style/included.css

makes easier the debugging of larger projects with many files + folders

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes easier the debugging of larger projects with many files + folders

Sorry, will response ASAP.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@milahu I think debugging should be fine. Bundler (or dev server) gives us filename, usually absolute or relative to cwd. We return compiled code and source map with sources relative to given filename (as they are stored in map). I think it's up to bundler/server to correctly resolve sources in resulting map. I tried some simple cases with rollup and it seemed to work as expected. Would appreciate if you could help me finding some repro with broken debugging experience so I could play with it if you have some time :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well thats easy ..
https://github.com/milahu/svelte-template--reproduce-basename-vs-filepath

actual result:
all paths are reduced to basename
(see my other comment, this is done by MagicString.generateDecodedMap)

expeted result:
optionally show the full file path, for example src/menu/App.svelte

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made the filename relative to cwd in rollup-plugin-svelte in sveltejs/rollup-plugin-svelte#131 because that's what I had seen in the couple example source maps I looked at. Assuming that's the correct behavior (I haven't fully followed the full discussion here), we could make that change to webpack's svelte-loader as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cloned it, launched and console logged the same. I also did some tweak after, will describe in my next comment. But if I hover App.svelte:2, I see full url to correct source file. Clicking also navigates to where I would expect.

}
}
}
Expand Down Expand Up @@ -97,6 +98,9 @@ export class StringWithSourcemap {
return this;
}

// compute last line length before mutating
const column_offset = last_line_length(this.string);

this.string += other.string;

const m1 = this.map;
Expand All @@ -117,24 +121,24 @@ export class StringWithSourcemap {
const segment_list = m2.mappings[line];
for (let segment = 0; segment < segment_list.length; segment++) {
const seg = segment_list[segment];
if (seg[1]) seg[1] = new_source_idx[seg[1]];
if (seg[4]) seg[4] = new_name_idx[seg[4]];
if (seg[1] >= 0) seg[1] = new_source_idx[seg[1]];
if (seg[4] >= 0) seg[4] = new_name_idx[seg[4]];
}
}
} else if (sources_idx_changed) {
for (let line = 0; line < m2.mappings.length; line++) {
const segment_list = m2.mappings[line];
for (let segment = 0; segment < segment_list.length; segment++) {
const seg = segment_list[segment];
if (seg[1]) seg[1] = new_source_idx[seg[1]];
if (seg[1] >= 0) seg[1] = new_source_idx[seg[1]];
}
}
} else if (names_idx_changed) {
for (let line = 0; line < m2.mappings.length; line++) {
const segment_list = m2.mappings[line];
for (let segment = 0; segment < segment_list.length; segment++) {
const seg = segment_list[segment];
if (seg[4]) seg[4] = new_name_idx[seg[4]];
if (seg[4] >= 0) seg[4] = new_name_idx[seg[4]];
}
}
}
Expand All @@ -146,7 +150,6 @@ export class StringWithSourcemap {
// 2. first line of second map
// columns of 2 must be shifted

const column_offset = last_line_length(this.string);
if (m2.mappings.length > 0 && column_offset > 0) {
const first_line = m2.mappings[0];
for (let i = 0; i < first_line.length; i++) {
Expand All @@ -164,7 +167,17 @@ export class StringWithSourcemap {
}

static from_processed(string: string, map?: DecodedSourceMap): StringWithSourcemap {
if (map) return new StringWithSourcemap(string, map);
if (map) {
// ensure that count of source map mappings lines
// is equal to count of generated code lines
// (some tools may produce less)
const missing_lines = string.split('\n').length - map.mappings.length;
for (let i = 0; i < missing_lines; i++) {
map.mappings.push([]);
}
return new StringWithSourcemap(string, map);
}

if (string == '') return new StringWithSourcemap();
map = { version: 3, names: [], sources: [], mappings: [] };

Expand Down
79 changes: 78 additions & 1 deletion test/sourcemaps/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,81 @@
import MagicString from 'magic-string';
import * as assert from 'assert';
import { getLocator } from 'locate-character';
import MagicString, { Bundle } from 'magic-string';

type AssertMappedParameters = {
code: string;
filename?: string;
input: string | ReturnType<typeof getLocator>;
input_code?: string;
preprocessed: any;
};

export function assert_mapped(
{ code, filename, input, input_code, preprocessed }: AssertMappedParameters
) {
const locate_input = typeof input === 'function' ? input : getLocator(input);
if (filename === undefined) filename = 'input.svelte';
if (input_code === undefined) input_code = code;

const source_loc = locate_input(input_code);
assert.notEqual(
source_loc,
undefined,
`failed to locate "${input_code}" in "${filename}"`
);

const transformed_loc = preprocessed.locate_1(code);
assert.notEqual(
transformed_loc,
undefined,
`failed to locate "${code}" in transformed "${filename}"`
);

assert.deepEqual(
preprocessed.mapConsumer.originalPositionFor(transformed_loc),
{
source: filename,
name: null,
line: source_loc.line + 1,
column: source_loc.column
},
`incorrect mappings for "${input_code}" in "${filename}"`
);
}

export function assert_not_located(
code: string,
locate: ReturnType<typeof getLocator>,
filename = 'input.svelte'
) {
assert.equal(
locate(code),
undefined,
`located "${code}" that should be removed from ${filename}`
);
}

export function magic_string_bundle(
inputs: Array<{ code: string | MagicString, filename: string }>,
filename = 'bundle.js',
separator = '\n'
) {
const bundle = new Bundle({ separator });
inputs.forEach(({ code, filename }) => {
bundle.addSource({
filename,
content: typeof code === 'string' ? new MagicString(code) : code
});
});
return {
code: bundle.toString(),
map: bundle.generateMap({
source: filename,
hires: true,
includeContent: false
})
};
}

export function magic_string_preprocessor_result(filename: string, src: MagicString) {
return {
Expand Down
24 changes: 24 additions & 0 deletions test/sourcemaps/samples/external/_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { magic_string_bundle } from '../../helpers';

export const COMMON = ':global(html) { height: 100%; }\n';

// TODO: removing '\n' breaks test
// - _actual.svelte.map looks correct
// - _actual.css.map adds reference to </style> on input.svelte
// - Most probably caused by bug in current magic-string version (fixed in 0.25.7)
export const STYLES = '.awesome { color: orange; }\n';

export default {
css_map_sources: ['common.scss', 'styles.scss'],
js_map_sources: [],
preprocess: [
{
style: () => {
return magic_string_bundle([
{ filename: 'common.scss', code: COMMON },
{ filename: 'styles.scss', code: STYLES }
]);
}
}
]
};
3 changes: 3 additions & 0 deletions test/sourcemaps/samples/external/input.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<style lang="scss" src="./styles.scss"></style>

<div class="awesome">Divs ftw!</div>
26 changes: 26 additions & 0 deletions test/sourcemaps/samples/external/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { assert_mapped } from '../../helpers';
import { COMMON, STYLES } from './_config';

export function test({ input, preprocessed }) {
// Transformed script, main file
assert_mapped({
filename: 'input.svelte',
code: 'Divs ftw!',
input: input.locate,
preprocessed
});

// External files
assert_mapped({
filename: 'common.scss',
code: 'height: 100%;',
input: COMMON,
preprocessed
});
assert_mapped({
filename: 'styles.scss',
code: 'color: orange;',
input: STYLES,
preprocessed
});
}
33 changes: 33 additions & 0 deletions test/sourcemaps/samples/sourcemap-basename/_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { magic_string_bundle } from '../../helpers';

export const component_filepath = 'src/input.svelte';

export const component_file_basename = 'input.svelte';

// as output by external tool for src/external_code.css (relative to src/input.svelte)
export const external_relative_filename = 'external_code.css';

const external_code = `
span {
--external_code-var: 1px;
}
`;

export default {
css_map_sources: [external_relative_filename],
js_map_sources: [],
preprocess: [
{
style: ({ content, filename }) => {
const external =`/* Filename from preprocess: ${filename} */` + external_code;
return magic_string_bundle([
{ code: external, filename: external_relative_filename },
{ code: content, filename }
]);
}
}
],
options: {
filename: component_filepath
}
};
3 changes: 3 additions & 0 deletions test/sourcemaps/samples/sourcemap-basename/input.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<style src="external_code.css"></style>

<span>Hello world</span>
Loading