Skip to content

Commit 37193ee

Browse files
committed
implement extends
1 parent 12571e2 commit 37193ee

File tree

2 files changed

+262
-24
lines changed

2 files changed

+262
-24
lines changed

crates/pgt_lsp/tests/server.rs

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use anyhow::Error;
33
use anyhow::Result;
44
use anyhow::bail;
55
use biome_deserialize::Merge;
6+
use biome_deserialize::StringSet;
67
use futures::Sink;
78
use futures::SinkExt;
89
use futures::Stream;
@@ -1326,7 +1327,6 @@ async fn multiple_projects() -> Result<()> {
13261327
})
13271328
.await?
13281329
.unwrap();
1329-
println!("{:?}", res_ws_two);
13301330

13311331
// only the first one has a db connection and should return completion items
13321332
assert!(!match res_ws_one {
@@ -1343,3 +1343,203 @@ async fn multiple_projects() -> Result<()> {
13431343

13441344
Ok(())
13451345
}
1346+
1347+
#[tokio::test]
1348+
async fn extends_config() -> Result<()> {
1349+
let factory = ServerFactory::default();
1350+
let mut fs = MemoryFileSystem::default();
1351+
let test_db = get_new_test_db().await;
1352+
1353+
let setup = r#"
1354+
create table public.extends_config_test (
1355+
id serial primary key,
1356+
name varchar(255) not null
1357+
);
1358+
"#;
1359+
1360+
test_db
1361+
.execute(setup)
1362+
.await
1363+
.expect("Failed to setup test database");
1364+
1365+
// shared config with default db connection
1366+
let conf_with_db = PartialConfiguration::init();
1367+
fs.insert(
1368+
url!("postgrestools.jsonc").to_file_path().unwrap(),
1369+
serde_json::to_string_pretty(&conf_with_db).unwrap(),
1370+
);
1371+
1372+
// test_one extends the shared config but sets our test db
1373+
let mut conf_with_db = PartialConfiguration::init();
1374+
conf_with_db.merge_with(PartialConfiguration {
1375+
extends: Some(StringSet::from_iter(["../postgrestools.jsonc".to_string()])),
1376+
db: Some(PartialDatabaseConfiguration {
1377+
database: Some(
1378+
test_db
1379+
.connect_options()
1380+
.get_database()
1381+
.unwrap()
1382+
.to_string(),
1383+
),
1384+
..Default::default()
1385+
}),
1386+
..Default::default()
1387+
});
1388+
fs.insert(
1389+
url!("test_one/postgrestools.jsonc").to_file_path().unwrap(),
1390+
serde_json::to_string_pretty(&conf_with_db).unwrap(),
1391+
);
1392+
1393+
// test_two extends it but keeps the default one
1394+
let mut conf_without_db = PartialConfiguration::init();
1395+
conf_without_db.merge_with(PartialConfiguration {
1396+
extends: Some(StringSet::from_iter(["../postgrestools.jsonc".to_string()])),
1397+
..Default::default()
1398+
});
1399+
fs.insert(
1400+
url!("test_two/postgrestools.jsonc").to_file_path().unwrap(),
1401+
serde_json::to_string_pretty(&conf_without_db).unwrap(),
1402+
);
1403+
1404+
let (service, client) = factory
1405+
.create_with_fs(None, DynRef::Owned(Box::new(fs)))
1406+
.into_inner();
1407+
1408+
let (stream, sink) = client.split();
1409+
let mut server = Server::new(service);
1410+
1411+
let (sender, _) = channel(CHANNEL_BUFFER_SIZE);
1412+
let reader = tokio::spawn(client_handler(stream, sink, sender));
1413+
1414+
server.initialize_workspaces().await?;
1415+
server.initialized().await?;
1416+
1417+
server.load_configuration().await?;
1418+
1419+
server
1420+
.open_named_document(
1421+
"select from public.extends_config_test;\n",
1422+
url!("test_one/document.sql"),
1423+
"sql",
1424+
)
1425+
.await?;
1426+
1427+
server
1428+
.change_named_document(
1429+
url!("test_one/document.sql"),
1430+
3,
1431+
vec![TextDocumentContentChangeEvent {
1432+
range: Some(Range {
1433+
start: Position {
1434+
line: 0,
1435+
character: 7,
1436+
},
1437+
end: Position {
1438+
line: 0,
1439+
character: 7,
1440+
},
1441+
}),
1442+
range_length: Some(0),
1443+
text: " ".to_string(),
1444+
}],
1445+
)
1446+
.await?;
1447+
1448+
let res_ws_one = server
1449+
.get_completion(CompletionParams {
1450+
work_done_progress_params: WorkDoneProgressParams::default(),
1451+
partial_result_params: PartialResultParams::default(),
1452+
context: None,
1453+
text_document_position: TextDocumentPositionParams {
1454+
text_document: TextDocumentIdentifier {
1455+
uri: url!("test_one/document.sql"),
1456+
},
1457+
position: Position {
1458+
line: 0,
1459+
character: 8,
1460+
},
1461+
},
1462+
})
1463+
.await?
1464+
.unwrap();
1465+
1466+
server
1467+
.open_named_document(
1468+
"select from public.users;\n",
1469+
url!("test_two/document.sql"),
1470+
"sql",
1471+
)
1472+
.await?;
1473+
1474+
server
1475+
.change_named_document(
1476+
url!("test_two/document.sql"),
1477+
3,
1478+
vec![TextDocumentContentChangeEvent {
1479+
range: Some(Range {
1480+
start: Position {
1481+
line: 0,
1482+
character: 7,
1483+
},
1484+
end: Position {
1485+
line: 0,
1486+
character: 7,
1487+
},
1488+
}),
1489+
range_length: Some(0),
1490+
text: " ".to_string(),
1491+
}],
1492+
)
1493+
.await?;
1494+
1495+
let res_ws_two = server
1496+
.get_completion(CompletionParams {
1497+
work_done_progress_params: WorkDoneProgressParams::default(),
1498+
partial_result_params: PartialResultParams::default(),
1499+
context: None,
1500+
text_document_position: TextDocumentPositionParams {
1501+
text_document: TextDocumentIdentifier {
1502+
uri: url!("test_two/document.sql"),
1503+
},
1504+
position: Position {
1505+
line: 0,
1506+
character: 8,
1507+
},
1508+
},
1509+
})
1510+
.await?
1511+
.unwrap();
1512+
1513+
let items_one = match res_ws_one {
1514+
CompletionResponse::Array(ref a) => a,
1515+
CompletionResponse::List(ref l) => &l.items,
1516+
};
1517+
1518+
// test one should have our test db connection and should return the completion items for the `extends_config_test` table
1519+
assert!(items_one.iter().any(|item| {
1520+
item.label_details.clone().is_some_and(|details| {
1521+
details
1522+
.description
1523+
.is_some_and(|desc| desc.contains("public.extends_config_test"))
1524+
})
1525+
}));
1526+
1527+
let items_two = match res_ws_two {
1528+
CompletionResponse::Array(ref a) => a,
1529+
CompletionResponse::List(ref l) => &l.items,
1530+
};
1531+
1532+
// test two should not have a db connection and should not return the completion items for the `extends_config_test` table
1533+
assert!(!items_two.iter().any(|item| {
1534+
item.label_details.clone().is_some_and(|details| {
1535+
details
1536+
.description
1537+
.is_some_and(|desc| desc.contains("public.extends_config_test"))
1538+
})
1539+
}));
1540+
1541+
server.shutdown().await?;
1542+
reader.abort();
1543+
1544+
Ok(())
1545+
}

crates/pgt_workspace/src/configuration.rs

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,34 +31,41 @@ pub struct LoadedConfiguration {
3131
}
3232

3333
impl LoadedConfiguration {
34-
/// Return the path of the **directory** where the configuration is
35-
pub fn directory_path(&self) -> Option<&Path> {
36-
self.directory_path.as_deref()
37-
}
38-
39-
/// Return the path of the **file** where the configuration is
40-
pub fn file_path(&self) -> Option<&Path> {
41-
self.file_path.as_deref()
42-
}
43-
}
44-
45-
impl From<Option<ConfigurationPayload>> for LoadedConfiguration {
46-
fn from(value: Option<ConfigurationPayload>) -> Self {
34+
fn try_from_payload(
35+
value: Option<ConfigurationPayload>,
36+
fs: &DynRef<'_, dyn FileSystem>,
37+
) -> Result<Self, WorkspaceError> {
4738
let Some(value) = value else {
48-
return LoadedConfiguration::default();
39+
return Ok(LoadedConfiguration::default());
4940
};
5041

5142
let ConfigurationPayload {
43+
external_resolution_base_path,
5244
configuration_file_path,
53-
deserialized: partial_configuration,
54-
..
45+
deserialized: mut partial_configuration,
5546
} = value;
5647

57-
LoadedConfiguration {
48+
partial_configuration.apply_extends(
49+
fs,
50+
&configuration_file_path,
51+
&external_resolution_base_path,
52+
)?;
53+
54+
Ok(Self {
5855
configuration: partial_configuration,
5956
directory_path: configuration_file_path.parent().map(PathBuf::from),
6057
file_path: Some(configuration_file_path),
61-
}
58+
})
59+
}
60+
61+
/// Return the path of the **directory** where the configuration is
62+
pub fn directory_path(&self) -> Option<&Path> {
63+
self.directory_path.as_deref()
64+
}
65+
66+
/// Return the path of the **file** where the configuration is
67+
pub fn file_path(&self) -> Option<&Path> {
68+
self.file_path.as_deref()
6269
}
6370
}
6471

@@ -68,7 +75,7 @@ pub fn load_configuration(
6875
config_path: ConfigurationPathHint,
6976
) -> Result<LoadedConfiguration, WorkspaceError> {
7077
let config = load_config(fs, config_path)?;
71-
Ok(LoadedConfiguration::from(config))
78+
LoadedConfiguration::try_from_payload(config, fs)
7279
}
7380

7481
/// - [Result]: if an error occurred while loading the configuration file.
@@ -123,7 +130,7 @@ fn load_config(
123130
ConfigurationPathHint::None => file_system.working_directory().unwrap_or_default(),
124131
};
125132

126-
// We first search for `postgrestools.jsonc`
133+
// We first search for `postgrestools.jsonc` files
127134
if let Some(auto_search_result) = file_system.auto_search(
128135
&configuration_directory,
129136
ConfigName::file_names().as_slice(),
@@ -344,9 +351,10 @@ impl PartialConfigurationExt for PartialConfiguration {
344351
extend_entry_as_path
345352
.extension()
346353
.map(OsStr::as_encoded_bytes),
347-
Some(b"json" | b"jsonc")
354+
Some(b"jsonc")
348355
) {
349-
relative_resolution_base_path.join(extend_entry)
356+
// Normalize the path to handle relative segments like "../"
357+
normalize_path(&relative_resolution_base_path.join(extend_entry))
350358
} else {
351359
fs.resolve_configuration(extend_entry.as_str(), external_resolution_base_path)
352360
.map_err(|error| {
@@ -369,7 +377,7 @@ impl PartialConfigurationExt for PartialConfiguration {
369377
err.to_string(),
370378
)
371379
.with_verbose_advice(markup! {
372-
"Biome tried to load the configuration file \""<Emphasis>{
380+
"Postgres Tools tried to load the configuration file \""<Emphasis>{
373381
extend_configuration_file_path.display().to_string()
374382
}</Emphasis>"\" in \"extends\" using \""<Emphasis>{
375383
external_resolution_base_path.display().to_string()
@@ -438,6 +446,36 @@ impl PartialConfigurationExt for PartialConfiguration {
438446
}
439447
}
440448

449+
/// Normalizes a path, resolving '..' and '.' segments without requiring the path to exist
450+
fn normalize_path(path: &Path) -> PathBuf {
451+
let mut components = Vec::new();
452+
for component in path.components() {
453+
match component {
454+
std::path::Component::ParentDir => {
455+
if !components.is_empty() {
456+
components.pop();
457+
}
458+
}
459+
std::path::Component::Normal(c) => components.push(c),
460+
std::path::Component::CurDir => {}
461+
c @ std::path::Component::RootDir | c @ std::path::Component::Prefix(_) => {
462+
components.clear();
463+
components.push(c.as_os_str());
464+
}
465+
}
466+
}
467+
468+
if components.is_empty() {
469+
PathBuf::from("/")
470+
} else {
471+
let mut result = PathBuf::new();
472+
for component in components {
473+
result.push(component);
474+
}
475+
result
476+
}
477+
}
478+
441479
#[cfg(test)]
442480
mod tests {
443481
use super::*;

0 commit comments

Comments
 (0)